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/.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/_agent-sdk.yml b/.github/workflows/_agent-sdk.yml
index f679bed0..8ff6f779 100644
--- a/.github/workflows/_agent-sdk.yml
+++ b/.github/workflows/_agent-sdk.yml
@@ -33,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/.github/workflows/dependency-monitor.yml b/.github/workflows/dependency-monitor.yml
index c9e74f67..b3b29c67 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 ad4a6c02..75e0af08 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -271,6 +271,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
@@ -353,6 +369,7 @@ jobs:
- cargo-deny-advisories
- agent-sdk-check
- npm-package-layout
+ - issue-triage-checks
- duplicate-code
- codeql
steps:
@@ -369,6 +386,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: |
@@ -399,6 +417,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/CHANGELOG.md b/CHANGELOG.md
index 3bcfa621..0a80e871 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
+### Upstream sync
+
+- Upstream main at `1c25754`: the live session name in Status (#445), the top-level bridge test glob (#471), issue triage CI (#474), the fullscreen Git diff viewer (#476), unified session picking with normalized saved history (#477), the unified chat layout and Markdown lists (#478), the composer prompt glyph on sent messages (#480) and the configurable single-row footer (#481). The welcome crab keeps this fork's pixel art and clock; #479's review fixes to its scheduling are kept.
+- `/diff` is upstream's Git viewer (HEAD, staged and unstaged comparisons). The fork's `/diff`, which also listed the session's Edit and Write diffs, is removed.
+- The session picker is upstream's searchable picker. Printable keys now search, so compact-first resume moved to Tab, tagging to Ctrl+T and deletion to Ctrl+D; `/resume` takes no argument (resume by exact title stays available as `claude-rs resume
`). Rows show upstream's title fallback (name, first prompt, summary).
+- The footer is upstream's configurable row. The fork's session name, usage-limit windows and finished-agent count are new fields in it; the plan row, agent shelf and `statusLine` row stay around it. The fork's two-row responsive footer is removed.
+- Code blocks inside lists and quotes use the fork's shaded code style, and links in lists are shortened like other links.
+
## [0.15.2-fork.7] - 2026-10-16
### Features
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index fc5e3507..dcbeb1dd 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
@@ -112,7 +119,11 @@ runtime layout, and manual bridge overrides.
`node scripts/dev/check-all.mjs` runs the PR-gating checks from `.github/workflows/pr.yml` and `_agent-sdk.yml` and prints a PASS/FAIL table (`--rust`, `--bridge`, `--quick`, `--audit`; `--quick` skips the serial terminal-tests re-run, the fake-bridge syntax check and the audits; see its header). `nix develop` provides CI's Rust channel (from `rust-toolchain.toml`), Node 24, Bun, `cargo-deny`, `jq`, `tmux` and `shellcheck`; it leaves the one-job build limit below alone.
-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).
The individual commands, which match the core checks in `.github/workflows/pr.yml`:
diff --git a/agent-sdk/src/bridge.contract.test.ts b/agent-sdk/src/bridge.contract.test.ts
index dc6b4fb0..18d6cb96 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 });
}
});
@@ -596,6 +591,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)};
@@ -1317,27 +1317,33 @@ test("resume of an unknown title or id reports the failure under the requested i
} finally { await cleanup(); }
});
-test("spawned bridge returns working diff for the connected session directory", async () => {
- const cwd = mkdtempSync(join(tmpdir(), "claude-rs-bridge-diff-"));
- const { bridge, cleanup } = ultracodeFixtureBridge();
+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 {
- const { execFileSync } = await import("node:child_process");
- execFileSync("git", ["-C", cwd, "init", "--quiet"]);
- writeFileSync(join(cwd, "file with spaces.txt"), "base\n");
- execFileSync("git", ["-C", cwd, "add", "."]);
- execFileSync("git", ["-C", cwd, "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--quiet", "-m", "base"]);
- writeFileSync(join(cwd, "file with spaces.txt"), "base\nchanged\n");
- bridge.writeCommand({ command: "create_session", cwd, launch_settings: {} });
- const connected = await nextMatching(bridge, event => event.event === "connected");
- bridge.writeCommand({ command: "get_working_diff", session_id: connected.session_id, request_id: "diff-one" });
- const result = await nextMatching(bridge, event => event.event === "working_diff");
- assert.equal(result.request_id, "diff-one");
- assert.equal(result.session_id, connected.session_id);
- assert.match(JSON.stringify(result.files), /file with spaces\.txt[\s\S]*\+changed/);
- } finally {
- await cleanup();
- rmSync(cwd, { recursive: true, force: true });
- }
+ 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 () => {
diff --git a/agent-sdk/src/bridge.test.ts b/agent-sdk/src/bridge.test.ts
index 25d11752..87df8e20 100644
--- a/agent-sdk/src/bridge.test.ts
+++ b/agent-sdk/src/bridge.test.ts
@@ -2342,7 +2342,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 3d29ea0c..d0dfc5f6 100644
--- a/agent-sdk/src/bridge.ts
+++ b/agent-sdk/src/bridge.ts
@@ -17,7 +17,6 @@ import type {
RewindTarget,
} from "./types.js";
import { parseCommandEnvelope } from "./bridge/commands.js";
-import { workingDiff } from "./bridge/git_diff.js";
import {
writeEvent,
failConnection,
@@ -40,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,
@@ -157,37 +157,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(
@@ -966,14 +945,6 @@ async function handleCommand(
}
switch (command.command) {
- case "get_working_diff": {
- const session = sessionById(command.session_id);
- const result = session && !session.closing
- ? await workingDiff(session.cwd)
- : { files: [], error: "No active session is available." };
- writeEvent({ event: "working_diff", session_id: command.session_id, ...result }, requestId);
- return;
- }
case "initialize":
case "create_session":
case "resume_session":
diff --git a/agent-sdk/src/bridge/commands.ts b/agent-sdk/src/bridge/commands.ts
index 8ce2d0bd..8a8fa360 100644
--- a/agent-sdk/src/bridge/commands.ts
+++ b/agent-sdk/src/bridge/commands.ts
@@ -573,11 +573,6 @@ export function parseCommandEnvelope(line: string): {
command: "get_usage",
session_id: expectString(raw, "session_id", "get_usage"),
};
- case "get_working_diff":
- return {
- command: "get_working_diff",
- session_id: expectString(raw, "session_id", "get_working_diff"),
- };
case "list_subagents":
return {
command: "list_subagents",
diff --git a/agent-sdk/src/bridge/git_diff.test.ts b/agent-sdk/src/bridge/git_diff.test.ts
deleted file mode 100644
index bb2f5d13..00000000
--- a/agent-sdk/src/bridge/git_diff.test.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import assert from "node:assert/strict";
-import { execFileSync } from "node:child_process";
-import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import test from "node:test";
-import { workingDiff } from "./git_diff.js";
-
-function git(cwd: string, ...args: string[]): void {
- execFileSync("git", ["-C", cwd, ...args], { stdio: "ignore" });
-}
-
-function repository(): string {
- const cwd = mkdtempSync(join(tmpdir(), "claude-rs-diff-"));
- git(cwd, "init", "--quiet");
- writeFileSync(join(cwd, "file with spaces.txt"), "base\n");
- writeFileSync(join(cwd, "staged.txt"), "base\n");
- git(cwd, "add", ".");
- git(cwd, "-c", "user.name=Test", "-c", "user.email=test@example.invalid", "commit", "--quiet", "-m", "base");
- return cwd;
-}
-
-test("working diff includes staged and unstaged edits, spaces, and untracked text", async () => {
- const cwd = repository();
- try {
- writeFileSync(join(cwd, "file with spaces.txt"), "base\nworking edit\n");
- writeFileSync(join(cwd, "staged.txt"), "base\nstaged edit\n");
- git(cwd, "add", "staged.txt");
- writeFileSync(join(cwd, "staged.txt"), "base\nstaged edit\nworking edit\n");
- writeFileSync(join(cwd, "new file.txt"), "new content\n");
- const result = await workingDiff(cwd);
- assert.equal(result.error, undefined);
- const byPath = new Map(result.files.map((file) => [file.path, file]));
- assert.match(byPath.get("file with spaces.txt")?.diff ?? "", /\+working edit/);
- assert.match(byPath.get("staged.txt")?.diff ?? "", /\+staged edit[\s\S]*\+working edit/);
- assert.match(byPath.get("new file.txt")?.diff ?? "", /\+\+\+ b\/new file\.txt[\s\S]*\+new content/);
- assert.equal(byPath.get("new file.txt")?.status, "??");
- } finally {
- rmSync(cwd, { recursive: true, force: true });
- }
-});
-
-test("working diff reports empty and non-repository states", async () => {
- const cwd = repository();
- const other = mkdtempSync(join(tmpdir(), "claude-rs-not-repo-"));
- try {
- assert.deepEqual(await workingDiff(cwd), { files: [] });
- const missing = await workingDiff(other);
- assert.deepEqual(missing.files, []);
- assert.match(missing.error ?? "", /not a Git repository/);
- } finally {
- rmSync(cwd, { recursive: true, force: true });
- rmSync(other, { recursive: true, force: true });
- }
-});
-
-test("working diff includes staged, unstaged, and untracked files before the first commit", async () => {
- const cwd = mkdtempSync(join(tmpdir(), "claude-rs-unborn-diff-"));
- try {
- git(cwd, "init", "--quiet");
- writeFileSync(join(cwd, "initial file.txt"), "staged line\n");
- git(cwd, "add", "initial file.txt");
- writeFileSync(join(cwd, "initial file.txt"), "staged line\nworking line\n");
- writeFileSync(join(cwd, "untracked.txt"), "untracked line\n");
- const result = await workingDiff(cwd);
- assert.equal(result.error, undefined);
- const byPath = new Map(result.files.map((file) => [file.path, file]));
- assert.match(byPath.get("initial file.txt")?.diff ?? "", /\+staged line/);
- assert.match(byPath.get("initial file.txt")?.diff ?? "", /\+working line/);
- assert.match(byPath.get("untracked.txt")?.diff ?? "", /\+untracked line/);
- } finally {
- rmSync(cwd, { recursive: true, force: true });
- }
-});
-
-test("working diff marks binary untracked content without reading it as text", async () => {
- const cwd = repository();
- try {
- writeFileSync(join(cwd, "binary.dat"), Buffer.from([0, 1, 2]));
- const result = await workingDiff(cwd);
- assert.equal(result.files.find((file) => file.path === "binary.dat")?.diff, "Binary untracked file; content preview unavailable.");
- assert.deepEqual(readFileSync(join(cwd, "binary.dat")), Buffer.from([0, 1, 2]));
- } finally {
- rmSync(cwd, { recursive: true, force: true });
- }
-});
-
-test("working diff limits large untracked previews and total snapshot output", async () => {
- const cwd = repository();
- try {
- writeFileSync(join(cwd, "too-large.txt"), "x".repeat(256 * 1024 + 1));
- for (let index = 0; index < 5; index++) {
- writeFileSync(join(cwd, `large-${index}.txt`), "x".repeat(240 * 1024));
- }
- const result = await workingDiff(cwd);
- assert.match(result.error ?? "", /limited to/);
- assert.ok(result.files.length < 6);
- assert.ok(result.files.reduce((size, file) => size + Buffer.byteLength(file.diff), 0) <= 1024 * 1024);
- } finally {
- rmSync(cwd, { recursive: true, force: true });
- }
-});
diff --git a/agent-sdk/src/bridge/git_diff.ts b/agent-sdk/src/bridge/git_diff.ts
deleted file mode 100644
index 26b7d91f..00000000
--- a/agent-sdk/src/bridge/git_diff.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import { execFile } from "node:child_process";
-import { lstat, readFile } from "node:fs/promises";
-import { isAbsolute, relative, resolve, sep } from "node:path";
-import { promisify } from "node:util";
-
-const execFileAsync = promisify(execFile);
-const MAX_FILES = 80;
-const MAX_OUTPUT = 256 * 1024;
-const MAX_SNAPSHOT = 1024 * 1024;
-const TIMEOUT_MS = 3_000;
-
-export type WorkingDiffFile = { path: string; status: string; diff: string };
-export type WorkingDiffSnapshot = { files: WorkingDiffFile[]; error?: string };
-
-async function git(cwd: string, args: string[], signal: AbortSignal): Promise {
- const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
- timeout: TIMEOUT_MS,
- maxBuffer: MAX_OUTPUT,
- windowsHide: true,
- encoding: "utf8",
- signal,
- });
- return stdout;
-}
-
-function inside(root: string, path: string): boolean {
- const rel = relative(root, path);
- return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
-}
-
-async function untrackedDiff(root: string, name: string): Promise {
- const file = resolve(root, name);
- if (!inside(root, file)) return "File is outside the repository.";
- const stat = await lstat(file);
- if (!stat.isFile()) return "Untracked non-regular file; content preview unavailable.";
- if (stat.size > MAX_OUTPUT) return `Untracked file exceeds ${MAX_OUTPUT} bytes; preview unavailable.`;
- const bytes = await readFile(file);
- if (bytes.includes(0)) return "Binary untracked file; content preview unavailable.";
- const lines = bytes.toString("utf8").split("\n");
- if (lines.at(-1) === "") lines.pop();
- return [`--- /dev/null`, `+++ b/${name}`, `@@ -0,0 +1,${lines.length} @@`, ...lines.map((line) => `+${line}`)].join("\n");
-}
-
-export async function workingDiff(cwd: string): Promise {
- const controller = new AbortController();
- const deadline = setTimeout(() => controller.abort(), 8_000);
- try {
- const root = (await git(cwd, ["rev-parse", "--show-toplevel"], controller.signal)).trim();
- const raw = await git(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], controller.signal);
- let hasHead = true;
- try {
- await git(root, ["rev-parse", "--verify", "--quiet", "HEAD"], controller.signal);
- } catch (error) {
- if (typeof error === "object" && error !== null && "code" in error && error.code === 1) {
- hasHead = false;
- } else {
- throw error;
- }
- }
- const records = raw.split("\0");
- const paths = new Map();
- for (let i = 0; i < records.length && paths.size < MAX_FILES; i++) {
- const record = records[i];
- if (!record) continue;
- const status = record.slice(0, 2);
- const name = record.slice(3);
- if (status.includes("R") || status.includes("C")) i++;
- if (name && !paths.has(name)) paths.set(name, status);
- }
- const files: WorkingDiffFile[] = [];
- let bytes = 0;
- let truncated = paths.size === MAX_FILES;
- for (const [name, status] of paths) {
- const diff = status === "??"
- ? await untrackedDiff(root, name)
- : hasHead
- ? await git(root, ["diff", "HEAD", "--no-ext-diff", "--no-textconv", "--no-color", "--", name], controller.signal)
- : [
- await git(root, ["diff", "--cached", "--no-ext-diff", "--no-textconv", "--no-color", "--", name], controller.signal),
- await git(root, ["diff", "--no-ext-diff", "--no-textconv", "--no-color", "--", name], controller.signal),
- ].filter(Boolean).join("\n");
- bytes += Buffer.byteLength(diff);
- if (bytes > MAX_SNAPSHOT) {
- truncated = true;
- break;
- }
- files.push({ path: name, status, diff: diff || "Binary or metadata-only change; text diff unavailable." });
- }
- return { files, ...(truncated ? { error: `Working diff limited to ${MAX_FILES} files and ${MAX_SNAPSHOT} bytes.` } : {}) };
- } catch (error) {
- const message = error instanceof Error ? error.message : String(error);
- return { files: [], error: message.includes("not a git repository") ? "This directory is not a Git repository." : `Could not read Git changes: ${message}` };
- } finally {
- clearTimeout(deadline);
- }
-}
diff --git a/agent-sdk/src/bridge/history.test.ts b/agent-sdk/src/bridge/history.test.ts
index 962f449f..2150e072 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";
@@ -27,8 +28,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" });
@@ -41,23 +45,26 @@ 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", "-"], {
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);
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");
@@ -225,12 +232,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,
@@ -238,25 +250,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]]);
-});
-
-test("resume preserves user text that resembles SDK local command markup", () => {
- const base = { session_id: "fixture", parent_tool_use_id: null, parent_agent_id: null };
- const command = "/rename\n rename\n Project check";
- const updates = mapSessionMessagesToUpdates([
- { ...base, type: "user", uuid: "rename", message: { role: "user", content: command } },
- { ...base, type: "user", uuid: "ordinary", message: { role: "user", content: "Explain literal XML" } },
+ 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(
- updates.filter(update => update.type === "user_message_chunk").map(update => [update.source_message_uuid, update.content]),
- [
- ["rename", { type: "text", text: command }],
- ["ordinary", { type: "text", text: "Explain literal XML" }],
- ],
- );
+ 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 af947492..29f50e9a 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;
-}
-
/**
* What a subagent's task_started record would have said, read from the Agent
* call that launched it. Claude Code 2.1.300 saves no task_started record for a
@@ -323,23 +311,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") {
@@ -454,30 +442,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(`${name}>`, 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");
- // A background agent the user stopped is saved as "killed" (Claude Code 2.1.300).
- if (taskId && (status === "completed" || status === "failed" || status === "stopped" || status === "killed")) {
- 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/logger.ts b/agent-sdk/src/bridge/logger.ts
index ec7e6355..cefb134a 100644
--- a/agent-sdk/src/bridge/logger.ts
+++ b/agent-sdk/src/bridge/logger.ts
@@ -122,7 +122,6 @@ function commandSessionId(command: BridgeCommand): string | undefined {
case "get_status_snapshot":
case "get_context_usage":
case "get_usage":
- case "get_working_diff":
case "list_subagents":
case "get_subagent_messages":
case "stop_subagent":
@@ -180,7 +179,6 @@ function commandToolCallId(command: BridgeCommand): string | undefined {
case "get_status_snapshot":
case "get_context_usage":
case "get_usage":
- case "get_working_diff":
case "list_subagents":
case "get_subagent_messages":
case "stop_subagent":
@@ -241,7 +239,6 @@ function eventToolCallId(event: BridgeEvent): string | undefined {
case "interaction_cancelled":
case "context_usage":
case "usage_snapshot":
- case "working_diff":
case "subagents_listed":
case "subagent_messages":
case "subagent_stop_result":
@@ -301,7 +298,6 @@ function protocolEventLevel(event: BridgeEvent): LogLevel {
case "settings_result":
case "context_usage":
case "usage_snapshot":
- case "working_diff":
case "subagents_listed":
case "subagent_messages":
case "subagent_stop_result":
diff --git a/agent-sdk/src/bridge/settings_catalog.ts b/agent-sdk/src/bridge/settings_catalog.ts
index 9a85da6c..134b51f3 100644
--- a/agent-sdk/src/bridge/settings_catalog.ts
+++ b/agent-sdk/src/bridge/settings_catalog.ts
@@ -14,6 +14,7 @@ const DEFINITIONS: Definition[] = [
["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.composerStyle", "Composer style", "Filled uses the dark message surface; Rules uses the terminal background with session-colored top and bottom lines.", "string", ["filled", "rules"], ["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 721b15cb..013cc096 100644
--- a/agent-sdk/src/bridge/settings_layout.ts
+++ b/agent-sdk/src/bridge/settings_layout.ts
@@ -35,6 +35,7 @@ const CATEGORY_SETTINGS: Record = {
"presentation.showStatusInTerminalTab",
"presentation.copyFullResponse",
"presentation.composerStyle",
+ "presentation.footer",
"respectGitignore",
"preferredNotifChannel",
"notifications.actionsRequired",
@@ -154,6 +155,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 2c4db872..3b893be9 100644
--- a/agent-sdk/src/bridge/settings_service.test.ts
+++ b/agent-sdk/src/bridge/settings_service.test.ts
@@ -83,6 +83,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/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..dd03d8a9
--- /dev/null
+++ b/agent-sdk/src/bridge/transcript_presentation.ts
@@ -0,0 +1,110 @@
+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 = `${name}>`;
+ 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");
+ // A background agent the user stopped is saved as "killed" (Claude Code 2.1.300).
+ if (taskId && (status === "completed" || status === "failed" || status === "stopped" || status === "killed")) {
+ 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/agent-sdk/src/types.ts b/agent-sdk/src/types.ts
index 1405de9e..41b93fd0 100644
--- a/agent-sdk/src/types.ts
+++ b/agent-sdk/src/types.ts
@@ -905,7 +905,6 @@ export interface SettingsResult {
export type BridgeCommand =
| { command: "list_sessions" }
- | { command: "get_working_diff"; session_id: string }
| { command: "inspect_settings"; session_id: string; app_settings_path?: string }
| { command: "mutate_setting"; session_id: string; app_settings_path?: string; mutation: SettingsMutation }
| {
@@ -1165,7 +1164,6 @@ export interface SideQuestionMetadata {
}
export type BridgeEvent =
- | { event: "working_diff"; session_id: string; files: Array<{ path: string; status: string; diff: string }>; error?: string }
| { event: "settings_result"; session_id: string; result: SettingsResult }
| {
event: "connected";
diff --git a/docs/src/commands.md b/docs/src/commands.md
index e98fbde2..54c6b6bc 100644
--- a/docs/src/commands.md
+++ b/docs/src/commands.md
@@ -19,7 +19,7 @@ Use `/docs commands` in the app to render the live merged command list into chat
| `/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. |
| `/search` | `/search [query]` | Search retained user, assistant, and visible tool text. |
-| `/diff` | `/diff` | Browse retained session tool diffs and current Git working-tree changes. |
+| `/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. |
@@ -37,7 +37,7 @@ 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_or_title]` | Open the session picker (refreshed from the SDK each time it opens, with the active session marked `[current]`), or resume an exact ID or displayed title. |
+| `/resume` | `/resume` | Search and select a session in the fullscreen picker, refreshed from the SDK each time it opens. Tab resumes a large session compact-first; Ctrl+T tags and Ctrl+D deletes the selected session. |
| `/rewind` | `/rewind ` | Restore conversation, code, or both to a previous user message. |
| `/reload` | `/reload` | Re-read skills and output styles from disk for the active session. |
| `/export` | `/export [path]` | Save the complete conversation as a Markdown file. |
@@ -48,12 +48,46 @@ Enter completes the selected slash command or argument and submits it when ready
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. A title must match the displayed title exactly; if it names more than one session, or none, the app shows a notice that lists the candidate IDs or says the session is unknown, and the composer stays usable. 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.
+
+`/resume` asks the SDK for the session list each time it opens, so the active session and sessions other processes wrote are listed; until that answer arrives only Escape works. Each row shows the session's saved size and its SDK tag. Tab resumes a session of 1 MiB or more and compacts it before the first prompt. Ctrl+T edits the selected session's tag (Enter saves, an empty tag clears it, Ctrl+U empties the field) and Ctrl+D deletes the selected session after `y` confirms; the active session cannot be deleted.
+
+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 from the terminal, use `claude-rs resume `, or an exact session title; a title that names several sessions is refused with their IDs.
+
+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.
+
+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.
@@ -92,7 +126,7 @@ Commands that Claude Code runs locally, such as `/rename`, `/color` or `/context
`/search` and the remappable `Ctrl+F` shortcut open a local search viewer. Type or paste a query, use Enter or Down for the next match and Shift+Enter or Up for the previous match, and press Esc to return to chat. The highlighted excerpt is from retained session content only; older content evicted by the app's history limit and archived transcripts are not searched.
-Rewinding a conversation keeps the model, effort and permission mode currently selected in the session. On resume, the public Agent SDK can return a local command as XML-like user text without identifying its origin. The history preserves that text exactly, so a command such as `/rename` may appear in its raw record form until the SDK exposes a reliable command marker.
+Rewinding a conversation keeps the model, effort and permission mode currently selected in the session.
## Status Tab
diff --git a/docs/src/settings.md b/docs/src/settings.md
index 3e244fef..90286399 100644
--- a/docs/src/settings.md
+++ b/docs/src/settings.md
@@ -17,7 +17,7 @@ Claude Code Rust has a fullscreen settings surface with multiple tabs. These sla
The settings surface is session-aware. Settings load when the app's session starts, and some tabs need an active session before they can show live data.
-The user-scoped **Composer style** setting chooses `filled` (the standard shaded input) or `rules` (a transparent input with session-accent top and bottom rules). The footer shows the current session name when the SDK reports one, a context-use meter when context data is available, and a plan row from the latest confirmed main-agent `TodoWrite` or SDK Task list. Background SDK tasks and subagent runs are excluded from plan progress; an empty confirmed list clears the row. The base footer uses one row when identity, location and gauges fit, and two rows on narrower terminals. Default permission mode and disabled fast mode are hidden; effective fast mode, permission requests and MCP authentication needs remain visible. Context and SDK rate gauges turn yellow at 70% and red at 90%. The plan and agent shelf grow above these base rows. While agents run, the shelf lists up to four agents, capped by terminal height, with their reported model, current activity and SDK usage. Below 60 columns it shows name, model and elapsed; below 24 terminal rows it shows only counts. When all finish, a compact agent count remains in the gauges. Ctrl+T opens the task and agent list. API-key and other sessions without plan limits do not show invented rate values.
+The user-scoped **Composer style** setting chooses `filled` (the standard shaded input) or `rules` (a transparent input with session-accent top and bottom rules). The status row below the composer is the configurable footer described under [Footer](#footer). Above it, a plan row shows the latest confirmed main-agent `TodoWrite` or SDK Task list; background SDK tasks and subagent runs are excluded from plan progress, and an empty confirmed list clears the row. While agents run, an agent shelf above the status row lists up to four agents, capped by terminal height, with their reported model, current activity and SDK usage. Below 60 columns it shows name, model and elapsed; below 24 terminal rows it shows only counts. When all finish, the footer's finished-agents field keeps their count. Ctrl+T opens the task and agent list. API-key and other sessions without plan limits do not show invented rate values.
When the SDK's user-scoped `statusLine` setting specifies a command, the bridge runs it after the session connects and displays its bounded output in a separate footer row. Project and local settings cannot supply this executable command. The command receives one JSON object on stdin containing `session_id`, `cwd`, `workspace.current_dir`, and `workspace.project_dir`; `model.id` and `model.display_name`, `context_window.used_percentage`, `remaining_percentage`, and `context_window_size`, and `rate_limits.five_hour` or `seven_day` appear only when the public SDK supplies them. There is no synthetic token count or rate-limit window. The command is refreshed after session results and rate events, and at the configured `refreshInterval` when set; execution times out after two seconds and output is limited to 4 KiB. Its output is rendered as plain text with terminal control sequences removed.
@@ -84,7 +84,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.
@@ -169,7 +169,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.
@@ -324,6 +324,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, session name, working directory, Git branch, context remaining, usage limits and finished agents 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, branch requires a Git branch, the session name requires a title the SDK reports, usage limits require SDK five-hour or seven-day data, and finished agents appear once a session's agents have all stopped. Context remaining and usage limits turn yellow at 70% used and red at 90%.
+
+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`, `sessionTitle`, `directory`, `gitBranch`, `contextRemaining`, `rateLimits` and `agents`. 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/agent/client.rs b/src/agent/client.rs
index fe014771..18a72b25 100644
--- a/src/agent/client.rs
+++ b/src/agent/client.rs
@@ -828,13 +828,6 @@ impl AgentConnection {
})
}
- pub fn get_working_diff(&self, session_id: String, request_id: String) -> anyhow::Result<()> {
- self.send(CommandEnvelope {
- request_id: Some(request_id),
- command: BridgeCommand::GetWorkingDiff { session_id },
- })
- }
-
/// Request the complete saved history for /export; chunks come back under `request_id`.
pub fn export_session_history(
&self,
diff --git a/src/agent/events.rs b/src/agent/events.rs
index 63fcbd91..ee900deb 100644
--- a/src/agent/events.rs
+++ b/src/agent/events.rs
@@ -40,12 +40,6 @@ pub struct HistoryChunk {
/// Messages sent from the backend bridge path to the App/UI layer.
pub enum ClientEvent {
- WorkingDiffReceived {
- session_id: String,
- request_id: Option,
- files: Vec,
- error: Option,
- },
SettingsResultReceived {
session_id: String,
request_id: Option,
@@ -321,7 +315,6 @@ impl ClientEvent {
| Self::StatusSnapshotReceived { session_id, .. }
| Self::ContextUsageReceived { session_id, .. }
| Self::StructuredUsageReceived { session_id, .. }
- | Self::WorkingDiffReceived { session_id, .. }
| Self::SubagentsListed { session_id, .. }
| Self::SubagentMessagesReceived { session_id, .. }
| Self::SubagentStopResult { session_id, .. }
diff --git a/src/agent/wire.rs b/src/agent/wire.rs
index 2e0ed454..f9d69010 100644
--- a/src/agent/wire.rs
+++ b/src/agent/wire.rs
@@ -219,9 +219,6 @@ pub enum BridgeCommand {
GetUsage {
session_id: String,
},
- GetWorkingDiff {
- session_id: String,
- },
ListSubagents {
session_id: String,
},
@@ -322,7 +319,6 @@ impl BridgeCommand {
Self::GetStatusSnapshot { .. } => "get_status_snapshot",
Self::GetContextUsage { .. } => "get_context_usage",
Self::GetUsage { .. } => "get_usage",
- Self::GetWorkingDiff { .. } => "get_working_diff",
Self::ListSubagents { .. } => "list_subagents",
Self::GetSubagentMessages { .. } => "get_subagent_messages",
Self::StopSubagent { .. } => "stop_subagent",
@@ -372,7 +368,6 @@ impl BridgeCommand {
| Self::GetStatusSnapshot { session_id }
| Self::GetContextUsage { session_id }
| Self::GetUsage { session_id }
- | Self::GetWorkingDiff { session_id }
| Self::ListSubagents { session_id }
| Self::GetSubagentMessages { session_id, .. }
| Self::StopSubagent { session_id, .. }
@@ -428,7 +423,6 @@ impl BridgeCommand {
| Self::GetStatusSnapshot { .. }
| Self::GetContextUsage { .. }
| Self::GetUsage { .. }
- | Self::GetWorkingDiff { .. }
| Self::ListSubagents { .. }
| Self::GetSubagentMessages { .. }
| Self::StopSubagent { .. }
@@ -465,21 +459,9 @@ pub struct SideQuestionMetadata {
pub refusal_fallback: Option,
}
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct WorkingDiffFile {
- pub path: String,
- pub status: String,
- pub diff: String,
-}
-
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum BridgeEvent {
- WorkingDiff {
- session_id: String,
- files: Vec,
- error: Option,
- },
SettingsResult {
session_id: String,
result: super::settings::SettingsResult,
@@ -721,7 +703,6 @@ impl BridgeEvent {
#[must_use]
pub fn event_name(&self) -> &'static str {
match self {
- Self::WorkingDiff { .. } => "working_diff",
Self::Connected { .. } => "connected",
Self::AuthRequired { .. } => "auth_required",
Self::ConnectionFailed { .. } => "connection_failed",
@@ -771,8 +752,7 @@ impl BridgeEvent {
#[must_use]
pub fn session_id(&self) -> Option<&str> {
match self {
- Self::WorkingDiff { session_id, .. }
- | Self::Connected { session_id, .. }
+ Self::Connected { session_id, .. }
| Self::SessionUpdate { session_id, .. }
| Self::BtwResult { session_id, .. }
| Self::BtwFailed { session_id, .. }
@@ -826,7 +806,6 @@ impl BridgeEvent {
}
Self::QuestionRequest { request, .. } => Some(request.tool_call.tool_call_id.as_str()),
Self::Connected { .. }
- | Self::WorkingDiff { .. }
| Self::AuthRequired { .. }
| Self::ConnectionFailed { .. }
| Self::SessionUpdate { .. }
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..b1f745cc
--- /dev/null
+++ b/src/app/config/footer/tests.rs
@@ -0,0 +1,447 @@
+// 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 {
+ // Boxed: the fork's larger `App` would exceed clippy's future-size limit.
+ let (app, mut commands) = app();
+ let mut app = Box::new(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, 10);
+ key(&mut app, KeyCode::Up, KeyModifiers::CONTROL);
+ assert_eq!(editor(&app).selected, 9);
+ assert_eq!(editor(&app).layout().fields()[9].id, FooterFieldId::Model);
+ key(&mut app, KeyCode::Down, KeyModifiers::CONTROL);
+ key(&mut app, KeyCode::Left, KeyModifiers::NONE);
+ assert_eq!(editor(&app).selected, 5);
+ assert_eq!(editor(&app).layout().fields()[5].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[10], 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 {
+ // Boxed: the fork's larger `App` would exceed clippy's future-size limit.
+ let (app, mut commands) = app();
+ let mut app = Box::new(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("11/11").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(),
+ 6,
+ "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 c1183b48..3401132f 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
@@ -170,7 +174,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 {
@@ -192,6 +196,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/service/tests.rs b/src/app/config/service/tests.rs
index 43fd8985..4800a119 100644
--- a/src/app/config/service/tests.rs
+++ b/src/app/config/service/tests.rs
@@ -528,91 +528,79 @@ async fn inline_arrows_and_space_cycle_choices_and_render_acknowledged_values()
async fn settings_load_automatically_after_startup_without_a_manual_refresh() {
use crate::agent::model;
use ratatui::{Terminal, backend::TestBackend};
- tokio::task::LocalSet::new()
- .run_until(async {
- let mut app = App::test_default();
- app.status = crate::app::AppStatus::Connecting;
- let (connection, mut commands) = AgentConnection::test_channel();
- app.session_runtime.conn = Some(Rc::new(connection));
- crate::app::config::open(&mut app).expect("open during startup");
- assert!(app.config.last_error.is_none(), "startup is not a settings error");
- let mut terminal = Terminal::new(TestBackend::new(100, 30)).expect("terminal");
- terminal
- .draw(|frame| crate::ui::render_fullscreen_surface(frame, &mut app))
- .expect("startup render");
- let text: String = terminal
- .backend()
- .buffer()
- .content
- .iter()
- .map(ratatui::buffer::Cell::symbol)
- .collect();
- assert!(text.contains("Loading settings"));
- let cwd = app.cwd_raw.clone();
- crate::app::events::handle_client_event(
- &mut app,
- ClientEvent::Connected {
- session_id: model::SessionId::new("session-1"),
- cwd,
- current_model: model::CurrentModel::new("opus", "Opus", "Opus"),
- available_models: Vec::new(),
- mode: None,
- fast_mode_state: model::FastModeState::Off,
- fast_mode_disabled_reason: None,
- ultracode: None,
- history_updates: Vec::new(),
- },
- );
- assert!(
- app.config.pending_settings_request.is_some(),
- "startup must request settings automatically"
- );
- let inspect = loop {
- let command = commands.recv_envelope().await.expect("startup command");
- if matches!(command.command, BridgeCommand::InspectSettings { .. }) {
- break command;
- }
- };
- let resolved = snapshot(&app.cwd_raw, "German");
- crate::app::events::handle_client_event(
- &mut app,
- ClientEvent::SettingsResultReceived {
- session_id: "session-1".to_owned(),
- request_id: inspect.request_id,
- result: SettingsResult {
- persistence: SettingsPersistence::NotRequested,
- application: SettingsApplication::Blocked,
- snapshot: Some(resolved),
- error: None,
- },
+ Box::pin(tokio::task::LocalSet::new().run_until(async {
+ let mut app = App::test_default();
+ app.status = crate::app::AppStatus::Connecting;
+ let (connection, mut commands) = AgentConnection::test_channel();
+ app.session_runtime.conn = Some(Rc::new(connection));
+ crate::app::config::open(&mut app).expect("open during startup");
+ assert!(app.config.last_error.is_none(), "startup is not a settings error");
+ let mut terminal = Terminal::new(TestBackend::new(100, 30)).expect("terminal");
+ terminal
+ .draw(|frame| crate::ui::render_fullscreen_surface(frame, &mut app))
+ .expect("startup render");
+ let text: String =
+ terminal.backend().buffer().content.iter().map(ratatui::buffer::Cell::symbol).collect();
+ assert!(text.contains("Loading settings"));
+ let cwd = app.cwd_raw.clone();
+ crate::app::events::handle_client_event(
+ &mut app,
+ ClientEvent::Connected {
+ session_id: model::SessionId::new("session-1"),
+ cwd,
+ current_model: model::CurrentModel::new("opus", "Opus", "Opus"),
+ available_models: Vec::new(),
+ mode: None,
+ fast_mode_state: model::FastModeState::Off,
+ fast_mode_disabled_reason: None,
+ ultracode: None,
+ history_updates: Vec::new(),
+ },
+ );
+ assert!(
+ app.config.pending_settings_request.is_some(),
+ "startup must request settings automatically"
+ );
+ let inspect = loop {
+ let command = commands.recv_envelope().await.expect("startup command");
+ if matches!(command.command, BridgeCommand::InspectSettings { .. }) {
+ break command;
+ }
+ };
+ let resolved = snapshot(&app.cwd_raw, "German");
+ crate::app::events::handle_client_event(
+ &mut app,
+ ClientEvent::SettingsResultReceived {
+ session_id: "session-1".to_owned(),
+ request_id: inspect.request_id,
+ result: SettingsResult {
+ persistence: SettingsPersistence::NotRequested,
+ application: SettingsApplication::Blocked,
+ snapshot: Some(resolved),
+ error: None,
},
- );
- terminal
- .draw(|frame| crate::ui::render_fullscreen_surface(frame, &mut app))
- .expect("loaded settings");
- let text: String = terminal
- .backend()
- .buffer()
- .content
- .iter()
- .map(ratatui::buffer::Cell::symbol)
- .collect();
- assert_eq!(app.config.saved_value("language"), Some(&json!("German")));
- let rows: Vec = terminal
- .backend()
- .buffer()
- .content
- .chunks(100)
- .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect())
- .collect();
- assert!(
- rows.iter()
- .any(|row| row.contains("Language") && row.trim_end().ends_with("German")),
- "loaded value must appear beside its setting: {text}"
- );
- assert!(app.config.last_error.is_none());
- })
- .await;
+ },
+ );
+ terminal
+ .draw(|frame| crate::ui::render_fullscreen_surface(frame, &mut app))
+ .expect("loaded settings");
+ let text: String =
+ terminal.backend().buffer().content.iter().map(ratatui::buffer::Cell::symbol).collect();
+ assert_eq!(app.config.saved_value("language"), Some(&json!("German")));
+ let rows: Vec = terminal
+ .backend()
+ .buffer()
+ .content
+ .chunks(100)
+ .map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect())
+ .collect();
+ assert!(
+ rows.iter().any(|row| row.contains("Language") && row.trim_end().ends_with("German")),
+ "loaded value must appear beside its setting: {text}"
+ );
+ assert!(app.config.last_error.is_none());
+ }))
+ .await;
}
#[tokio::test(flavor = "current_thread")]
diff --git a/src/app/config/state.rs b/src/app/config/state.rs
index d8d73464..9c20fd1c 100644
--- a/src/app/config/state.rs
+++ b/src/app/config/state.rs
@@ -164,17 +164,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,
}
}
@@ -189,6 +189,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;
@@ -196,6 +211,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/connect/event_dispatch.rs b/src/app/connect/event_dispatch.rs
index 6766c06d..3dc682cc 100644
--- a/src/app/connect/event_dispatch.rs
+++ b/src/app/connect/event_dispatch.rs
@@ -43,11 +43,6 @@ pub(super) async fn handle_bridge_event(
) {
let EventEnvelope { request_id, event } = envelope;
match event {
- crate::agent::wire::BridgeEvent::WorkingDiff { session_id, files, error } => {
- let _ = event_tx
- .send(ClientEvent::WorkingDiffReceived { session_id, request_id, files, error })
- .await;
- }
crate::agent::wire::BridgeEvent::Connected {
session_id,
cwd,
diff --git a/src/app/diff_view.rs b/src/app/diff_view.rs
deleted file mode 100644
index a41f2a39..00000000
--- a/src/app/diff_view.rs
+++ /dev/null
@@ -1,175 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-
-use crate::agent::{model, wire::WorkingDiffFile};
-use crate::app::{App, FullscreenView, MessageBlock, view};
-use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-use std::collections::BTreeMap;
-use std::path::Path;
-
-pub(crate) struct DiffEntry {
- pub path: String,
- pub status: Option,
- pub working_diff: Option,
- pub session_diff: Option,
-}
-
-pub(crate) struct DiffViewState {
- pub entries: Vec,
- pub selected: usize,
- pub scroll: usize,
- pub loading: bool,
- pub error: Option,
- pub rendered_session_diff: Option<(usize, u16, Vec>)>,
- request_id: String,
-}
-
-fn session_entries(app: &App) -> BTreeMap {
- let mut entries = BTreeMap::new();
- for message in &app.transcript.messages {
- for block in &message.blocks {
- let MessageBlock::ToolCall(tool) = block else { continue };
- for content in &tool.content {
- let model::ToolCallContent::Diff(diff) = content else { continue };
- let path = diff.path.strip_prefix(Path::new(&app.cwd_raw)).unwrap_or(&diff.path);
- let label = path.to_string_lossy().into_owned();
- entries.insert(
- label.clone(),
- DiffEntry {
- path: label,
- status: None,
- working_diff: None,
- session_diff: Some(diff.clone()),
- },
- );
- }
- }
- }
- entries
-}
-
-pub(crate) fn open(app: &mut App) {
- let Some(session_id) = app.session_runtime.session_id.as_ref().map(|id| id.as_str().to_owned())
- else {
- crate::app::events::push_submission_feedback(
- app,
- crate::app::SystemSeverity::Info,
- "Connect to a session before opening /diff.",
- );
- return;
- };
- let request_id = uuid::Uuid::new_v4().to_string();
- let entries = session_entries(app).into_values().collect();
- app.diff_view = Some(Box::new(DiffViewState {
- entries,
- selected: 0,
- scroll: 0,
- loading: true,
- error: None,
- rendered_session_diff: None,
- request_id: request_id.clone(),
- }));
- view::set_fullscreen_view(app, FullscreenView::Diff);
- request(app, session_id, request_id);
-}
-
-fn request(app: &mut App, session_id: String, request_id: String) {
- let Some(conn) = app.session_runtime.conn.as_ref() else { return };
- if let Err(error) = conn.get_working_diff(session_id, request_id)
- && let Some(state) = &mut app.diff_view
- {
- state.loading = false;
- state.error = Some(format!("Could not request Git changes: {error}"));
- }
-}
-
-fn refresh(app: &mut App) {
- let Some(session_id) = app.session_runtime.session_id.as_ref().map(|id| id.as_str().to_owned())
- else {
- return;
- };
- let request_id = uuid::Uuid::new_v4().to_string();
- let entries = session_entries(app).into_values().collect();
- app.diff_view = Some(Box::new(DiffViewState {
- entries,
- selected: 0,
- scroll: 0,
- loading: true,
- error: None,
- rendered_session_diff: None,
- request_id: request_id.clone(),
- }));
- request(app, session_id, request_id);
- app.request_active_surface_repaint();
-}
-
-pub(crate) fn apply(
- app: &mut App,
- session_id: &str,
- request_id: Option<&str>,
- files: Vec,
- error: Option,
-) {
- if app.session_runtime.session_id.as_ref().map(model::SessionId::as_str) != Some(session_id) {
- return;
- }
- let Some(state) = &mut app.diff_view else { return };
- if request_id != Some(state.request_id.as_str()) {
- return;
- }
- let selected_path = state.entries.get(state.selected).map(|entry| entry.path.clone());
- let mut entries: BTreeMap = std::mem::take(&mut state.entries)
- .into_iter()
- .map(|entry| (entry.path.clone(), entry))
- .collect();
- for file in files {
- let entry = entries.entry(file.path.clone()).or_insert_with(|| DiffEntry {
- path: file.path,
- status: None,
- working_diff: None,
- session_diff: None,
- });
- entry.status = Some(file.status);
- entry.working_diff = Some(file.diff);
- }
- state.entries = entries.into_values().collect();
- state.selected = selected_path
- .and_then(|path| state.entries.iter().position(|entry| entry.path == path))
- .unwrap_or(0);
- state.loading = false;
- state.error = error;
- state.rendered_session_diff = None;
- app.request_active_surface_repaint();
-}
-
-pub(crate) fn handle_key(app: &mut App, key: KeyEvent) {
- match (key.code, key.modifiers) {
- (KeyCode::Esc, _) => view::set_chat_surface(app),
- (KeyCode::Char('r' | 'R'), KeyModifiers::NONE | KeyModifiers::SHIFT) => refresh(app),
- (KeyCode::Up, _) => {
- if let Some(state) = &mut app.diff_view {
- state.selected = state.selected.saturating_sub(1);
- state.scroll = 0;
- state.rendered_session_diff = None;
- }
- }
- (KeyCode::Down, _) => {
- if let Some(state) = &mut app.diff_view {
- state.selected = (state.selected + 1).min(state.entries.len().saturating_sub(1));
- state.scroll = 0;
- state.rendered_session_diff = None;
- }
- }
- (KeyCode::PageUp, _) => {
- if let Some(state) = &mut app.diff_view {
- state.scroll = state.scroll.saturating_sub(20);
- }
- }
- (KeyCode::PageDown, _) => {
- if let Some(state) = &mut app.diff_view {
- state.scroll = state.scroll.saturating_add(20);
- }
- }
- _ => {}
- }
- app.request_active_surface_repaint();
-}
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..0764756f
--- /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::accent());
+ 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::accent())
+ .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() {
+ 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, crate::ui::theme::success()), (removed, crate::ui::theme::danger())]
+ {
+ 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::accent());
+ }
+ }
+ 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::accent());
+ }
+ }
+ 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::accent())
+ .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::accent(), "content pane retains focus");
+}
diff --git a/src/app/events/client/mod.rs b/src/app/events/client/mod.rs
index dbf5cfa0..32e99279 100644
--- a/src/app/events/client/mod.rs
+++ b/src/app/events/client/mod.rs
@@ -97,7 +97,6 @@ fn client_event_family(event: &ClientEvent) -> ClientEventFamily {
| ClientEvent::AuthCompleted { .. }
| ClientEvent::LogoutCompleted
| ClientEvent::StatusSnapshotReceived { .. }
- | ClientEvent::WorkingDiffReceived { .. }
| ClientEvent::ContextUsageReceived { .. }
| ClientEvent::RewindTargetsReceived { .. }
| ClientEvent::SessionHistoryChunk { .. }
diff --git a/src/app/events/client/session.rs b/src/app/events/client/session.rs
index e5758097..07024031 100644
--- a/src/app/events/client/session.rs
+++ b/src/app/events/client/session.rs
@@ -6,9 +6,6 @@ use crate::agent::events::ClientEvent;
#[allow(clippy::too_many_lines)]
pub(super) fn handle(app: &mut App, event: ClientEvent) {
match event {
- ClientEvent::WorkingDiffReceived { session_id, request_id, files, error } => {
- crate::app::diff_view::apply(app, &session_id, request_id.as_deref(), files, error);
- }
ClientEvent::SessionMutationResult {
session_id,
request_id,
@@ -192,8 +189,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 52d17b8a..e1eb9214 100644
--- a/src/app/events/mod.rs
+++ b/src/app/events/mod.rs
@@ -19,7 +19,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"))]
@@ -363,14 +363,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,
@@ -386,9 +390,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);
}
@@ -547,6 +551,8 @@ fn handle_session_update(app: &mut App, update: model::SessionUpdate) {
model::SessionUpdate::SessionTitleUpdate(title) => {
// An acknowledged clear must survive a failed list refresh (2026-10-13).
// Keep the existing snapshot consistent with the authoritative update.
+ 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())
@@ -554,6 +560,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::SessionColorUpdate(color) => {
if let Some(session_id) = app.session_runtime.session_id.as_ref()
diff --git a/src/app/events/session.rs b/src/app/events/session.rs
index 5f496fb7..52463f75 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};
@@ -104,9 +103,7 @@ pub(crate) 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
@@ -147,7 +144,10 @@ pub(crate) 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,
@@ -689,29 +689,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;
@@ -731,9 +708,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 5ae2a963..91b126f7 100644
--- a/src/app/events/session_reset.rs
+++ b/src/app/events/session_reset.rs
@@ -214,6 +214,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);
@@ -276,7 +284,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 661d4b6a..dfc623f3 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(
@@ -122,7 +107,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(
@@ -143,7 +128,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};
@@ -159,6 +144,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));
@@ -189,6 +175,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,
);
}
@@ -214,6 +201,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/client_events.rs b/src/app/events/tests/client_events.rs
index 5f247a92..1a9ead87 100644
--- a/src/app/events/tests/client_events.rs
+++ b/src/app/events/tests/client_events.rs
@@ -1,48 +1,6 @@
use super::*;
use pretty_assertions::assert_eq;
-#[test]
-fn diff_result_from_another_session_is_ignored_and_replacement_closes_pending_view() {
- let (mut app, mut commands) = app_with_bridge_connection();
- crate::app::diff_view::open(&mut app);
- let request = commands.try_recv().expect("working diff request");
- let request_id = request.request_id.expect("correlated request");
- assert!(app.diff_view.as_ref().is_some_and(|view| view.loading));
-
- let response = |session_id: &str| ClientEvent::WorkingDiffReceived {
- session_id: session_id.to_owned(),
- request_id: Some(request_id.clone()),
- files: vec![crate::agent::wire::WorkingDiffFile {
- path: "stale.txt".to_owned(),
- status: " M".to_owned(),
- diff: "+stale".to_owned(),
- }],
- error: None,
- };
- handle_client_event(&mut app, response("other-session"));
- assert!(app.diff_view.as_ref().is_some_and(|view| view.loading && view.entries.is_empty()));
-
- handle_client_event(
- &mut app,
- ClientEvent::SessionReplaced {
- session_id: model::SessionId::new("replacement"),
- cwd: "/replacement".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: Vec::new(),
- restored_input: None,
- },
- );
- assert!(app.diff_view.is_none());
- assert_eq!(app.surface_mode, SurfaceMode::Chat);
- handle_client_event(&mut app, response("test-session"));
- assert!(app.diff_view.is_none());
-}
-
#[test]
fn connected_updates_welcome_session_id_while_pristine() {
let mut app = make_test_app();
@@ -182,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.timeline.connected_at().is_none());
+
+ handle_client_event(&mut app, connected_event("claude-first"));
+
+ assert!(app.startup.timeline.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.timeline.mark_connected(first);
+
+ handle_client_event(&mut app, connected_event("claude-later"));
+
+ assert_eq!(app.startup.timeline.connected_at(), Some(first));
+}
+
#[test]
fn connected_reconciles_trust_for_new_cwd() {
let mut app = make_test_app();
@@ -2153,3 +2132,142 @@ fn history_chunk(request_id: Option<&str>, last: bool, error: Option<&str>) -> C
},
}
}
+
+#[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"),
+ ],
+ request_id: None,
+ },
+ );
+ 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"),
+ ],
+ request_id: None,
+ },
+ );
+ 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"),
+ ],
+ request_id: None,
+ },
+ );
+ 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")],
+ request_id: None,
+ },
+ );
+ 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")],
+ request_id: None,
+ },
+ );
+ 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")],
+ request_id: None,
+ },
+ );
+ 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(), request_id: None },
+ );
+ 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"),
+ ],
+ request_id: None,
+ },
+ );
+ 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 c6eeea94..927cbc43 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,
@@ -1246,16 +1248,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
@@ -1288,6 +1280,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.iter().any(|row| row == &format!(" ❯ {message}")),
+ "separate user marker and text for {message}, startup={startup}"
+ );
+ }
+ }
+}
+
#[test]
fn startup_resume_history_allows_immediate_prompt_submit() {
let (mut app, mut rx) = app_with_bridge_connection();
@@ -3670,14 +3748,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 69d80612..0f8309c1 100644
--- a/src/app/events/tests/slash_input.rs
+++ b/src/app/events/tests/slash_input.rs
@@ -319,7 +319,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 {
@@ -344,9 +344,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]
@@ -652,40 +652,31 @@ 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,
- tag: 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!(matches!(commands(&mut rx).as_slice(), [BridgeCommand::ListSessions]));
- }
- }
+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,
+ tag: 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));
+ // Opening the picker asks for a fresh listing, never a resume.
+ assert!(matches!(commands(&mut rx).as_slice(), [BridgeCommand::ListSessions]));
}
#[tokio::test(flavor = "current_thread")]
@@ -826,3 +817,60 @@ 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,
+ tag: None,
+ })
+ .collect();
+ draft(&mut app, "/resume");
+ enter(&mut app);
+ // The picker asks for a fresh listing first; its answer releases the keys.
+ assert!(matches!(commands(&mut rx).as_slice(), [BridgeCommand::ListSessions]));
+ let listing = app.session_picker.list_request.clone();
+ crate::app::session_picker::finish_refresh(&mut app, listing.as_deref());
+ 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/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/footer.rs b/src/app/footer.rs
new file mode 100644
index 00000000..7a97cd6b
--- /dev/null
+++ b/src/app/footer.rs
@@ -0,0 +1,225 @@
+// 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 {
+ SessionTitle,
+ PermissionMode,
+ Model,
+ Effort,
+ Ultracode,
+ FastMode,
+ Directory,
+ GitBranch,
+ ContextRemaining,
+ RateLimits,
+ Agents,
+}
+
+impl FooterFieldId {
+ pub(crate) const fn label(self) -> &'static str {
+ match self {
+ Self::SessionTitle => "Session name",
+ 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",
+ Self::RateLimits => "Usage limits",
+ Self::Agents => "Finished agents",
+ }
+ }
+}
+
+#[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::SessionTitle, Left),
+ (Id::Directory, Right),
+ (Id::GitBranch, Right),
+ (Id::ContextRemaining, Right),
+ (Id::RateLimits, Right),
+ (Id::Agents, 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(), 11);
+ 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(5, true), 5);
+ assert_eq!(layout.move_field(10, true), 10);
+ 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, 10);
+ assert_eq!(layout.fields()[moved].id, FooterFieldId::Model);
+ assert!(!layout.fields()[moved].visible);
+ let moved = layout.switch_side(moved);
+ assert_eq!(moved, 5);
+ assert_eq!(layout.fields()[moved].side, FooterSide::Left);
+ }
+}
diff --git a/src/app/input_submit.rs b/src/app/input_submit.rs
index 28173ddc..02e68144 100644
--- a/src/app/input_submit.rs
+++ b/src/app/input_submit.rs
@@ -215,12 +215,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);
@@ -355,7 +350,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();
@@ -422,16 +417,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 f1431b5a..d0634626 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -18,6 +18,7 @@ mod events;
pub(crate) mod export;
pub(crate) mod file_index;
mod focus;
+pub(crate) mod footer;
mod git_context;
mod inline_interactions;
pub(crate) mod input;
@@ -80,6 +81,7 @@ pub use settings::{AppSettings, UpdatePrompt};
pub(crate) use state::PlanSource;
#[cfg(test)]
pub(crate) use state::StartupTimeline;
+pub(crate) use state::TranscriptUpdateSource;
pub(crate) use state::format_elapsed;
pub use state::{
ActiveCompaction, App, AppStatus, AutocompleteKind, BlockCache, BtwExchangeBlock, CacheMetrics,
@@ -257,6 +259,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);
@@ -680,15 +683,18 @@ fn frame_is_animating(app: &App, now: Instant) -> bool {
}
/// Advances the spinner and requests the next draw while the frame animates.
-/// When an animation stops it 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 it 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() {
+ } else if app.spinner_last_advance_at.take().is_some()
+ && crate::ui::welcome_is_whole_transcript(app)
+ {
app.request_active_surface_repaint();
}
is_animating
diff --git a/src/app/session_picker.rs b/src/app/session_picker.rs
index c7b5c86f..374311e6 100644
--- a/src/app/session_picker.rs
+++ b/src/app/session_picker.rs
@@ -4,14 +4,69 @@
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, SessionPickerMutation, SystemSeverity};
+use super::{
+ App, AppStatus, RecentSessionInfo, SessionPickerMutation, SessionPickerState, SystemSeverity,
+};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
-pub(crate) const MAX_PICKER_SESSIONS: usize = 10;
+/// Sessions at least this large offer a compact-first resume.
pub(crate) const LARGE_SESSION_BYTES: u64 = 1024 * 1024;
+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 {
@@ -42,17 +97,8 @@ pub(crate) fn finish_refresh(app: &mut App, request_id: Option<&str>) {
return;
}
app.session_picker.list_request = None;
- let active =
- app.session_runtime.session_id.as_ref().map(crate::agent::model::SessionId::as_str);
- app.session_picker.selected = active
- .and_then(|active| {
- app.recent_sessions
- .iter()
- .take(MAX_PICKER_SESSIONS)
- .position(|session| session.session_id == active)
- })
- .unwrap_or(0);
- app.session_picker.scroll_offset = 0;
+ let active = app.session_runtime.session_id.as_ref().map(ToString::to_string);
+ reconcile_selection(app, active.as_deref());
}
/// End the picker's own refresh without discarding the most recent successful listing.
@@ -75,8 +121,36 @@ 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());
+}
+
+/// A paste lands in the tag draft while a session action is open, and in the search otherwise.
+pub(crate) fn handle_paste(app: &mut App, text: &str) -> bool {
+ if refresh_is_pending(app) {
+ return false;
+ }
+ if app.session_picker.mutation.is_some() {
+ paste_tag(app, text);
+ return true;
+ }
+ paste_search(app, text)
+}
+
+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;
}
@@ -102,59 +176,73 @@ 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) {
- close_picker(app);
- }
+ // Session actions sit on Ctrl chords and Tab, because every printable key edits the search.
+ if super::keys::is_ctrl_char_shortcut(key, 't') {
+ open_mutation(app, false);
+ return;
+ }
+ if super::keys::is_ctrl_char_shortcut(key, 'd') {
+ open_mutation(app, true);
+ return;
+ }
+
+ 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));
}
(KeyCode::Home, _) => app.session_picker.selected = 0,
(KeyCode::End, _) => app.session_picker.selected = session_count.saturating_sub(1),
- (KeyCode::Enter, KeyModifiers::NONE) => activate_selection(app),
- (KeyCode::Char('c'), KeyModifiers::NONE) => activate_compact_first(app),
+ (KeyCode::Enter, KeyModifiers::NONE) => resume_selected(app, false),
+ (KeyCode::Tab, KeyModifiers::NONE) => activate_compact_first(app),
(KeyCode::Right, KeyModifiers::NONE) => open_turn_selection(app),
- (KeyCode::Char('t'), KeyModifiers::NONE) => open_mutation(app, false),
- (KeyCode::Char('d'), KeyModifiers::NONE) => open_mutation(app, true),
(KeyCode::Esc, KeyModifiers::NONE) => close_picker(app),
_ => {}
}
}
fn open_mutation(app: &mut App, delete: bool) {
- let Some(session) = app.recent_sessions.get(app.session_picker.selected) else { return };
+ let Some(session) = selected_session(app) else { return };
+ let session_id = session.session_id.clone();
+ let tag = session.tag.clone();
if delete
&& app
.session_runtime
.session_id
.as_ref()
- .is_some_and(|active| active.as_str() == session.session_id)
+ .is_some_and(|active| active.as_str() == session_id)
{
app.session_picker.error =
Some("Switch sessions before deleting the active session.".to_owned());
return;
}
app.session_picker.error = None;
+ let revision = app.session_picker.list_revision;
app.session_picker.mutation = Some(Box::new(if delete {
- SessionPickerMutation::Delete {
- session_id: session.session_id.clone(),
- revision: app.session_picker.list_revision,
- request_id: None,
- }
+ SessionPickerMutation::Delete { session_id, revision, request_id: None }
} else {
SessionPickerMutation::Tag {
- session_id: session.session_id.clone(),
- revision: app.session_picker.list_revision,
- draft: session.tag.clone().unwrap_or_default(),
+ session_id,
+ revision,
+ draft: tag.unwrap_or_default(),
request_id: None,
}
}));
@@ -175,18 +263,16 @@ fn handle_mutation_key(app: &mut App, key: KeyEvent) {
match mutation {
SessionPickerMutation::Tag { draft, request_id: None, .. } => {
match (key.code, key.modifiers) {
+ (KeyCode::Char('u'), KeyModifiers::CONTROL) => draft.clear(),
(KeyCode::Char(ch), modifiers)
- if modifiers.is_empty() || modifiers == KeyModifiers::SHIFT =>
+ if !ch.is_control() && super::keys::is_printable_text_modifiers(modifiers) =>
{
draft.push(ch);
}
(KeyCode::Backspace, _) => {
draft.pop();
}
- (KeyCode::Char('u'), KeyModifiers::CONTROL) => draft.clear(),
- (KeyCode::Enter, _) => {
- submit_mutation(app);
- }
+ (KeyCode::Enter, _) => submit_mutation(app),
_ => {}
}
}
@@ -202,19 +288,18 @@ fn handle_mutation_key(app: &mut App, key: KeyEvent) {
/// A bracketed paste into the tag field lands where typed characters land (the
/// draft has no cursor, so the end). A tag is one line: line breaks become
/// spaces and other control characters are dropped, because a pasted newline
-/// cannot be typed here (Enter submits). The field has no length bound for
-/// typed input, so a paste has none either. Pastes are swallowed while a
-/// delete confirmation or a pending save owns the keys.
-pub(crate) fn handle_paste(app: &mut App, text: &str) -> bool {
- let Some(mutation) = app.session_picker.mutation.as_deref_mut() else { return false };
- if let SessionPickerMutation::Tag { draft, request_id: None, .. } = mutation {
+/// cannot be typed here (Enter submits). Pastes are swallowed while a delete
+/// confirmation or a pending save owns the keys.
+fn paste_tag(app: &mut App, text: &str) {
+ if let Some(SessionPickerMutation::Tag { draft, request_id: None, .. }) =
+ app.session_picker.mutation.as_deref_mut()
+ {
draft.extend(text.replace("\r\n", "\n").chars().filter_map(|pasted| match pasted {
'\n' | '\r' => Some(' '),
control if control.is_control() => None,
printable => Some(printable),
}));
}
- true
}
fn submit_mutation(app: &mut App) {
@@ -260,14 +345,12 @@ fn submit_mutation(app: &mut App) {
};
match result {
Ok(()) => {
- match app.session_picker.mutation.as_deref_mut() {
- Some(
- SessionPickerMutation::Tag { request_id: pending, .. }
- | SessionPickerMutation::Delete { request_id: pending, .. },
- ) => {
- *pending = Some(request_id);
- }
- None => {}
+ if let Some(
+ SessionPickerMutation::Tag { request_id: pending, .. }
+ | SessionPickerMutation::Delete { request_id: pending, .. },
+ ) = app.session_picker.mutation.as_deref_mut()
+ {
+ *pending = Some(request_id);
}
app.session_picker.error = None;
}
@@ -287,39 +370,37 @@ pub(crate) fn apply_mutation_result(
sessions: Option>,
) {
let Some(mutation) = app.session_picker.mutation.as_deref() else { return };
- let matches_request = match mutation {
+ let (expected_operation, id, pending) = match mutation {
SessionPickerMutation::Tag { session_id: id, request_id: pending, .. } => {
- operation == "tag"
- && id == session_id
- && pending.as_deref() == request_id
- && pending.is_some()
+ ("tag", id, pending)
}
SessionPickerMutation::Delete { session_id: id, request_id: pending, .. } => {
- operation == "delete"
- && id == session_id
- && pending.as_deref() == request_id
- && pending.is_some()
+ ("delete", id, pending)
}
};
- if !matches_request {
+ if operation != expected_operation
+ || id != session_id
+ || pending.is_none()
+ || pending.as_deref() != request_id
+ {
return;
}
if let Some(error) = error {
app.session_picker.error = Some(error);
- if let Some(mutation) = app.session_picker.mutation.as_deref_mut() {
- match mutation {
- SessionPickerMutation::Tag { request_id, .. }
- | SessionPickerMutation::Delete { request_id, .. } => *request_id = None,
- }
+ if let Some(
+ SessionPickerMutation::Tag { request_id, .. }
+ | SessionPickerMutation::Delete { request_id, .. },
+ ) = app.session_picker.mutation.as_deref_mut()
+ {
+ *request_id = None;
}
return;
}
+ app.session_picker.mutation = None;
if let Some(sessions) = sessions {
- app.session_picker.mutation = None;
app.session_picker.error = None;
crate::app::events::session::handle_sessions_listed_event(app, sessions);
} else {
- app.session_picker.mutation = None;
app.session_picker.error = Some(format!(
"Session action completed, but the session list could not refresh: {}",
refresh_error.unwrap_or_else(|| "no refreshed list received".to_owned())
@@ -328,6 +409,24 @@ pub(crate) fn apply_mutation_result(
}
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;
@@ -350,18 +449,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 {
@@ -369,8 +466,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;
@@ -389,8 +485,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();
}
@@ -425,24 +520,14 @@ fn activate_turn_selection(app: &mut App) {
view::set_chat_surface(app);
}
-fn activate_selection(app: &mut App) {
- resume_selected(app, false);
-}
-
fn activate_compact_first(app: &mut App) {
- let large = app
- .recent_sessions
- .get(app.session_picker.selected)
- .is_some_and(|session| session.file_size_bytes >= LARGE_SESSION_BYTES);
- if large {
+ if selected_session(app).is_some_and(|session| session.file_size_bytes >= LARGE_SESSION_BYTES) {
resume_selected(app, true);
}
}
fn resume_selected(app: &mut App, compact_first: bool) {
- 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();
@@ -474,10 +559,6 @@ fn resume_selected(app: &mut App, compact_first: bool) {
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::{apply_mutation_result, handle_key, handle_paste};
@@ -529,6 +610,10 @@ mod tests {
handle_key(app, KeyEvent::new(code, KeyModifiers::NONE));
}
+ fn ctrl(app: &mut App, ch: char) {
+ handle_key(app, KeyEvent::new(KeyCode::Char(ch), KeyModifiers::CONTROL));
+ }
+
fn tag_draft(app: &App) -> Option<&str> {
match app.session_picker.mutation.as_deref() {
Some(SessionPickerMutation::Tag { draft, .. }) => Some(draft),
@@ -537,14 +622,16 @@ mod tests {
}
#[test]
- fn paste_extends_the_tag_draft_on_one_line_and_is_ignored_elsewhere() {
+ fn paste_extends_the_tag_draft_on_one_line_and_searches_otherwise() {
let mut app = picker_app();
- assert!(!handle_paste(&mut app, "ignored"), "no mutation open: not consumed");
+ assert!(handle_paste(&mut app, "First"), "no action open: the paste searches");
+ assert_eq!(app.session_picker.query, "First");
- key(&mut app, KeyCode::Char('t'));
+ ctrl(&mut app, 't');
key(&mut app, KeyCode::Char('a'));
assert!(handle_paste(&mut app, "b\r\nc\nd\x1b[31me\u{7}\u{e9}"));
assert_eq!(tag_draft(&app), Some("ab c d[31me\u{e9}"));
+ assert_eq!(app.session_picker.query, "First", "typing into the tag leaves the search");
// Enter after a paste submits the pasted tag, not a second line.
let (connection, mut rx) = AgentConnection::test_channel();
@@ -553,22 +640,38 @@ mod tests {
let sent = rx.try_recv().expect("tag command");
assert!(matches!(
sent.command,
- BridgeCommand::TagSession { tag: Some(ref tag), .. } if tag == "ab c d[31me\u{e9}"
+ BridgeCommand::TagSession { tag: Some(ref tag), ref session_id }
+ if tag == "ab c d[31me\u{e9}" && session_id == "session-1"
));
// While the save is pending the field is read-only, as for typed keys.
assert!(handle_paste(&mut app, "late"));
assert_eq!(tag_draft(&app), Some("ab c d[31me\u{e9}"));
}
+ #[test]
+ fn session_actions_follow_the_filtered_selection() {
+ 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"));
+ ctrl(&mut app, 'd');
+ key(&mut app, KeyCode::Char('y'));
+ let sent = rx.try_recv().expect("confirmed deletion");
+ assert!(
+ matches!(sent.command, BridgeCommand::DeleteSession { session_id } if session_id == "session-2")
+ );
+ }
+
#[test]
fn paste_does_not_touch_a_delete_confirmation() {
let mut app = picker_app();
- key(&mut app, KeyCode::Char('d'));
+ ctrl(&mut app, 'd');
assert!(handle_paste(&mut app, "y"));
assert!(matches!(
app.session_picker.mutation.as_deref(),
Some(SessionPickerMutation::Delete { request_id: None, .. })
));
+ assert!(app.session_picker.query.is_empty());
}
#[test]
@@ -577,17 +680,18 @@ mod tests {
let (connection, mut rx) = AgentConnection::test_channel();
app.session_runtime.conn = Some(Rc::new(connection));
app.session_runtime.session_id = Some(crate::agent::model::SessionId::new("session-1"));
- key(&mut app, KeyCode::Char('d'));
+ ctrl(&mut app, 'd');
assert!(app.session_picker.mutation.is_none());
assert!(rx.try_recv().is_err());
key(&mut app, KeyCode::Down);
- key(&mut app, KeyCode::Char('d'));
+ ctrl(&mut app, 'd');
key(&mut app, KeyCode::Esc);
assert!(app.session_picker.mutation.is_none());
assert!(rx.try_recv().is_err());
+ assert_eq!(app.surface_mode, SurfaceMode::Fullscreen(FullscreenView::SessionPicker));
- key(&mut app, KeyCode::Char('d'));
+ ctrl(&mut app, 'd');
key(&mut app, KeyCode::Char('y'));
let sent = rx.try_recv().expect("confirmed deletion");
assert!(
@@ -602,8 +706,8 @@ mod tests {
app.recent_sessions[0].tag = Some("old".to_owned());
let (connection, mut rx) = AgentConnection::test_channel();
app.session_runtime.conn = Some(Rc::new(connection));
- key(&mut app, KeyCode::Char('t'));
- handle_key(&mut app, KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
+ ctrl(&mut app, 't');
+ ctrl(&mut app, 'u');
key(&mut app, KeyCode::Enter);
let sent = rx.try_recv().expect("tag clear");
assert!(
@@ -669,7 +773,7 @@ mod tests {
let mut app = picker_app();
let (connection, mut rx) = AgentConnection::test_channel();
app.session_runtime.conn = Some(Rc::new(connection));
- key(&mut app, KeyCode::Char('d'));
+ ctrl(&mut app, 'd');
app.session_picker.list_revision += 1;
app.session_picker.selected = 1;
key(&mut app, KeyCode::Char('y'));
@@ -677,6 +781,46 @@ mod tests {
assert!(app.session_picker.mutation.is_none());
}
+ #[test]
+ fn tab_resumes_compact_first_only_for_a_large_selected_session() {
+ let mut app = picker_app();
+ let (connection, mut rx) = AgentConnection::test_channel();
+ app.session_runtime.conn = Some(Rc::new(connection));
+ key(&mut app, KeyCode::Tab);
+ assert!(rx.try_recv().is_err());
+ assert_eq!(app.surface_mode, SurfaceMode::Fullscreen(FullscreenView::SessionPicker));
+ app.recent_sessions[0].file_size_bytes = super::LARGE_SESSION_BYTES;
+ key(&mut app, KeyCode::Tab);
+ assert!(matches!(rx.try_recv().expect("resume command").command,
+ BridgeCommand::ResumeSession { session_id, .. } if session_id == "session-1"));
+ assert!(app.startup.pending_compact_first("session-1"));
+ }
+
+ #[test]
+ fn a_pending_refresh_holds_the_keys_until_its_listing_arrives() {
+ let mut app = picker_app();
+ let (connection, mut rx) = AgentConnection::test_channel();
+ app.session_runtime.conn = Some(Rc::new(connection));
+ app.session_runtime.session_id = Some(crate::agent::model::SessionId::new("session-2"));
+ let conn = app.session_runtime.conn.clone().expect("connection");
+ super::begin_refresh(&mut app, &conn);
+ let sent = rx.try_recv().expect("listing request");
+ assert!(matches!(sent.command, BridgeCommand::ListSessions));
+ key(&mut app, KeyCode::Enter);
+ key(&mut app, KeyCode::Char('x'));
+ assert!(rx.try_recv().is_err());
+ assert!(app.session_picker.query.is_empty());
+
+ super::finish_refresh(&mut app, Some("another request"));
+ assert!(super::refresh_is_pending(&app));
+ super::finish_refresh(&mut app, sent.request_id.as_deref());
+ assert!(!super::refresh_is_pending(&app));
+ assert_eq!(
+ super::selected_session(&app).map(|session| session.session_id.as_str()),
+ Some("session-2")
+ );
+ }
+
#[test]
fn loading_state_ignores_navigation_keys() {
let mut app = picker_app();
@@ -728,30 +872,118 @@ mod tests {
}
#[test]
- fn compact_first_key_resumes_only_large_selected_session() {
+ 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));
- handle_key(&mut app, KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
+
+ handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
+
+ assert!(app.session_picker.turn_session_id.is_none());
+ assert!(!app.sdk_inventory.rewind_targets_in_flight);
assert!(rx.try_recv().is_err());
- app.recent_sessions[0].file_size_bytes = super::LARGE_SESSION_BYTES;
- handle_key(&mut app, KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
- assert!(matches!(rx.try_recv().expect("resume command").command,
+ 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"));
- assert!(app.startup.pending_compact_first("session-1"));
}
#[test]
- fn space_is_ignored_to_avoid_accidental_destructive_navigation() {
+ 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")
+ );
+ }
- handle_key(&mut app, KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
+ #[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")
+ );
+ }
+ }
- assert!(app.session_picker.turn_session_id.is_none());
- assert!(!app.sdk_inventory.rewind_targets_in_flight);
- assert!(rx.try_recv().is_err());
+ #[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]
@@ -770,35 +1002,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_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_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 84c71c61..c320a202 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
)
})
@@ -336,48 +334,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,
@@ -391,20 +347,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 9d4ecbf0..d47509ca 100644
--- a/src/app/slash/catalog.rs
+++ b/src/app/slash/catalog.rs
@@ -181,8 +181,8 @@ pub(crate) const APP_SLASH_COMMANDS: &[AppSlashCommandSpec] = &[
command: AppSlashCommand::Diff,
name: "/diff",
usage: "Usage: /diff",
- short_description: "Inspect file changes",
- long_description: "Browse retained session tool diffs and current Git working-tree changes.",
+ 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 {
@@ -335,9 +335,9 @@ pub(crate) const APP_SLASH_COMMANDS: &[AppSlashCommandSpec] = &[
AppSlashCommandSpec {
command: AppSlashCommand::Resume,
name: "/resume",
- usage: "Usage: /resume [session_id_or_title]",
- short_description: "Resume a session by ID or title",
- 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 {
@@ -417,13 +417,8 @@ impl AppSlashCommand {
#[allow(clippy::too_many_lines)]
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
@@ -432,6 +427,7 @@ impl AppSlashCommand {
}
}
Self::Config
+ | Self::Diff
| Self::Memory
| Self::Permissions
| Self::Sandbox
@@ -441,8 +437,7 @@ impl AppSlashCommand {
| Self::Plugins
| Self::Status
| Self::Usage
- | Self::Agents
- | Self::Diff => {
+ | Self::Agents => {
if args.is_empty() {
SubmissionClass::Fullscreen
} else {
@@ -463,7 +458,12 @@ impl AppSlashCommand {
}
_ => SubmissionClass::Invalid,
},
- Self::Compact | Self::Login | Self::Logout | Self::NewSession | Self::Reload => {
+ Self::Compact
+ | Self::Login
+ | Self::Logout
+ | Self::NewSession
+ | Self::Reload
+ | Self::Resume => {
if args.is_empty() {
SubmissionClass::TurnExclusive
} else {
@@ -503,7 +503,7 @@ impl AppSlashCommand {
SubmissionClass::Invalid
}
}
- Self::Export | Self::Resume => SubmissionClass::TurnExclusive,
+ Self::Export => SubmissionClass::TurnExclusive,
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 8bd4c2a9..34411484 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};
@@ -85,7 +81,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),
AppSlashCommand::Reload => handle_reload_submit(app),
AppSlashCommand::Export => handle_export_submit(app, text),
@@ -848,43 +844,15 @@ 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);
- if let Some(conn) = app.session_runtime.conn.clone() {
- crate::app::session_picker::begin_refresh(app, &conn);
- }
- return true;
- }
- let session_id = args.join(" ");
-
- push_user_message(app, format!("/resume {session_id}"));
- let Some(conn) = require_connection(app, "Cannot resume session: not connected yet.") else {
+fn handle_resume_submit(app: &mut App) -> bool {
+ push_user_message(app, "/resume");
+ let Some(conn) = require_connection(app, "Cannot open resume picker: not connected yet.")
+ else {
return true;
};
-
- set_command_pending(app, &format!("Resuming session {session_id}..."), None);
- 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);
+ // The listing held from connect predates this session and any written since.
+ crate::app::session_picker::begin_refresh(app, &conn);
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 2a45adef..d2a0c713 100644
--- a/src/app/slash/tests.rs
+++ b/src/app/slash/tests.rs
@@ -146,6 +146,7 @@ fn resolved_submission_is_the_active_turn_policy_authority() {
assert_eq!(classify("/cancel"), SubmissionClass::TurnControl);
for input in [
"/config",
+ "/diff",
"/memory",
"/permissions",
"/sandbox",
@@ -173,12 +174,18 @@ fn resolved_submission_is_the_active_turn_policy_authority() {
"/model sonnet",
"/new-session",
"/resume",
- "/resume Planning notes",
"/rewind user-1 conversation",
] {
assert_eq!(classify(input), SubmissionClass::TurnExclusive, "unexpected class for {input}");
}
- for input in ["/cancel extra", "/docs nope", "/plugins extra", "/rewind user-1 invalid"] {
+ for input in [
+ "/cancel extra",
+ "/diff extra",
+ "/docs nope",
+ "/plugins extra",
+ "/resume one two",
+ "/rewind user-1 invalid",
+ ] {
assert_eq!(classify(input), SubmissionClass::Invalid, "unexpected class for {input}");
}
}
@@ -1181,16 +1188,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(),
@@ -1203,7 +1210,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")]
@@ -1254,6 +1261,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");
};
@@ -1296,15 +1304,19 @@ fn resume_picker_preselects_current_session_when_listed() {
}
#[test]
-fn resume_with_title_words_sends_one_resume_request() {
+fn resume_with_arguments_reports_usage() {
let mut app = App::test_default();
let mut commands = attach_test_connection(&mut app);
let consumed = try_handle_submit(&mut app, "/resume abc-123 extra");
assert!(consumed);
- let request = commands.try_recv().expect("resume request");
- assert!(
- matches!(request.command, crate::agent::wire::BridgeCommand::ResumeSession { session_id, .. } if session_id == "abc-123 extra")
- );
+ let Some(last) = app.transcript.messages.last() else {
+ panic!("expected usage message");
+ };
+ let Some(MessageBlock::Text(block)) = last.blocks.first() else {
+ panic!("expected text block");
+ };
+ assert_eq!(block.text, "Usage: /resume");
+ assert!(commands.try_recv().is_err(), "arguments never resume a session");
}
#[test]
@@ -1381,42 +1393,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()
@@ -2066,7 +2042,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 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/app/state/app.rs b/src/app/state/app.rs
index 8b569f46..1166be46 100644
--- a/src/app/state/app.rs
+++ b/src/app/state/app.rs
@@ -13,7 +13,7 @@ pub struct App {
pub surface_mode: SurfaceMode,
pub(crate) copy_picker: Option,
pub(crate) search: Option>,
- pub(crate) diff_view: Option>,
+ pub(crate) diff_view: Option>,
/// The `/export` waiting for its history from the bridge.
pub(crate) history_export: Option,
/// `/agents` view state; subagent status itself stays in `sdk_inventory.tasks`.
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 5413178f..df02f3f5 100644
--- a/src/app/state/mod.rs
+++ b/src/app/state/mod.rs
@@ -57,6 +57,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 startup_timeline::StartupTimeline;
diff --git a/src/app/state/session_identity.rs b/src/app/state/session_identity.rs
index 86aa995e..1c5c9b28 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/state/types.rs b/src/app/state/types.rs
index 92ddc2e1..6bb963ac 100644
--- a/src/app/state/types.rs
+++ b/src/app/state/types.rs
@@ -76,7 +76,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,
@@ -92,6 +94,19 @@ pub struct SessionPickerState {
pub list_request: Option,
pub mutation: Option>,
pub error: Option,
+ /// 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, Eq)]
diff --git a/src/app/terminal_runtime/chat_session.rs b/src/app/terminal_runtime/chat_session.rs
index 929abb02..5555493b 100644
--- a/src/app/terminal_runtime/chat_session.rs
+++ b/src/app/terminal_runtime/chat_session.rs
@@ -1426,7 +1426,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,
@@ -1483,7 +1483,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());
}
@@ -1685,7 +1685,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()
@@ -1725,13 +1725,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]
@@ -1786,7 +1797,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();
@@ -1804,7 +1815,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(
@@ -1824,7 +1835,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")));
}
@@ -1860,11 +1871,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/app/terminal_runtime/chat_session/render_bench.rs b/src/app/terminal_runtime/chat_session/render_bench.rs
index bc207aca..05cc5e0e 100644
--- a/src/app/terminal_runtime/chat_session/render_bench.rs
+++ b/src/app/terminal_runtime/chat_session/render_bench.rs
@@ -245,12 +245,15 @@ mod tests {
let rows = screen_rows(&bench);
let tail = rows
.iter()
- .position(|row| row == "That is the full picture.")
+ .position(|row| row.trim() == "That is the full picture.")
.expect("the last line of the reply is on screen");
- assert!(rows[tail + 1].starts_with("Reading output"), "{:?}", rows[tail + 1]);
- assert!(rows[tail + 2].contains(&prompt_prefix_text()), "{:?}", rows[tail + 2]);
+ // The message layout closes a reply with one blank padding row.
+ assert_eq!(rows[tail + 1], "", "{:?}", rows[tail + 1]);
+ assert!(rows[tail + 2].starts_with("Reading output"), "{:?}", rows[tail + 2]);
+ assert!(rows[tail + 3].contains(&prompt_prefix_text()), "{:?}", rows[tail + 3]);
assert!(
- rows.last().is_some_and(|row| row.starts_with("fixture") && row.ends_with("/test")),
+ rows.last()
+ .is_some_and(|row| row.starts_with("[fixture]") && row.contains("Loc: /test")),
"{:?}",
rows.last()
);
@@ -276,9 +279,13 @@ mod tests {
// the activity row above the composer are what the live viewport still owns.
let tail = rows
.iter()
- .position(|row| row == "That is the full picture.")
+ .position(|row| row.trim() == "That is the full picture.")
.expect("the open tail of the reply is live");
- assert!(rows[tail + 1].contains('…'), "activity row follows: {:?}", rows[tail + 1]);
+ let activity = rows[tail + 1..].iter().find(|row| !row.is_empty());
+ assert!(
+ activity.is_some_and(|row| row.contains('…')),
+ "activity row follows: {activity:?}"
+ );
assert!(!rows.iter().any(|row| row.contains("Finding 11")), "{rows:#?}");
}
diff --git a/src/app/tests.rs b/src/app/tests.rs
index 24292805..b0a45718 100644
--- a/src/app/tests.rs
+++ b/src/app/tests.rs
@@ -522,42 +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,
- tag: 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();
@@ -663,18 +627,59 @@ fn welcome_hello_ends_with_exactly_one_more_draw() {
}
#[test]
-fn hello_schedules_no_redraws_while_the_crab_cannot_wave() {
+fn a_welcome_held_back_without_a_hello_still_gets_the_draw_that_commits_it() {
let connected = Instant::now();
- let at = connected + Duration::from_millis(100);
+ let mut app = welcomed_app(connected, false);
+ app.config.snapshot = None;
+ app.surface_dirty.chat.repaint = false;
- let mut unknown_preference = welcomed_app(connected, false);
- unknown_preference.config.snapshot = None;
- assert!(!frame_is_animating(&unknown_preference, at));
+ 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 hello 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 narrow = welcomed_app(connected, false);
- narrow.chat_render.observe_terminal_size(36, 34);
- assert!(!frame_is_animating(&narrow, at));
+ 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 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)));
}
#[test]
diff --git a/src/app/update_check/tests.rs b/src/app/update_check/tests.rs
index 94758d3b..9844534f 100644
--- a/src/app/update_check/tests.rs
+++ b/src/app/update_check/tests.rs
@@ -107,7 +107,7 @@ async fn checks_in_both_modes_persist_results_and_choose_manual_or_exit_installa
#[tokio::test(flavor = "current_thread")]
async fn check_results_preserve_revocation_and_reset_before_exit() {
- tokio::task::LocalSet::new().run_until(async {
+ Box::pin(tokio::task::LocalSet::new().run_until(async {
let (_directory, mut app) = fixture(Some(true));
let path = app.global_settings_path.clone().expect("settings path");
let (endpoint, server) = release_server("200 OK");
@@ -137,7 +137,7 @@ async fn check_results_preserve_revocation_and_reset_before_exit() {
std::fs::write(&path, "{broken").expect("invalid external edit");
assert!(automatic_update_action_with(&app, &cli, || InstallMethod::Npm).is_none());
assert_eq!(std::fs::read_to_string(path).expect("read"), "{broken");
- }).await;
+ })).await;
}
#[tokio::test(flavor = "current_thread")]
diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs
index 50138d30..4f710c53 100644
--- a/src/app/view/mod.rs
+++ b/src/app/view/mod.rs
@@ -91,6 +91,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;
@@ -101,6 +102,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;
@@ -109,9 +113,6 @@ fn clear_transient_view_state(app: &mut App) {
if app.surface_mode == SurfaceMode::Fullscreen(FullscreenView::Config) {
app.config.clear_overlay();
}
- if app.surface_mode == SurfaceMode::Fullscreen(FullscreenView::Diff) {
- app.diff_view = None;
- }
app.release_focus_target(crate::app::FocusTarget::Mention);
app.paste.burst.on_non_char_key(Instant::now());
}
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/activity_rows.rs b/src/ui/activity_rows.rs
index 85b822bf..8a78843a 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::accent());
- 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::accent())), 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::accent())));
- 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::accent()));
}
}
@@ -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/config/footer.rs b/src/ui/config/footer.rs
new file mode 100644
index 00000000..177c7f39
--- /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::tertiary())),
+ 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 e59d2643..638a37eb 100644
--- a/src/ui/config/mod.rs
+++ b/src/ui/config/mod.rs
@@ -4,6 +4,7 @@
mod action_overlay;
/// List, selection, badge and hint helpers shared by the fullscreen views.
pub(super) 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 40051bce..a4aafd5b 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::tertiary()
})),
- 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 e2fad96c..4f7391cc 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/diff.rs b/src/ui/diff.rs
index efaf1c9d..e65104ca 100644
--- a/src/ui/diff.rs
+++ b/src/ui/diff.rs
@@ -111,14 +111,6 @@ pub fn render_raw_unified_diff(text: &str) -> Vec> {
lines
}
-pub(crate) fn render_raw_unified_diff_window(
- text: &str,
- scroll: usize,
- height: usize,
-) -> Vec> {
- text.lines().skip(scroll).take(height).map(render_raw_diff_line).collect()
-}
-
fn render_raw_diff_line(line: &str) -> Line<'static> {
let style = if line.starts_with("diff --git ")
|| line.starts_with("index ")
@@ -148,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/diff_view.rs b/src/ui/diff_view.rs
deleted file mode 100644
index 97949714..00000000
--- a/src/ui/diff_view.rs
+++ /dev/null
@@ -1,92 +0,0 @@
-// SPDX-License-Identifier: Apache-2.0
-
-use crate::app::App;
-use crate::ui::{diff, theme};
-use ratatui::{
- Frame,
- layout::{Constraint, Direction, Layout, Margin},
- style::Style,
- text::Line,
- widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
-};
-
-pub(crate) fn render(frame: &mut Frame, app: &mut App) {
- let Some(state) = &mut app.diff_view else { return };
- let area = frame.area().inner(Margin { vertical: 1, horizontal: 1 });
- let rows = Layout::default()
- .direction(Direction::Vertical)
- .constraints([Constraint::Length(2), Constraint::Min(1), Constraint::Length(2)])
- .split(area);
- frame.render_widget(Paragraph::new("Diff · current session and working tree"), rows[0]);
- frame.render_widget(
- Paragraph::new("↑/↓ files · PgUp/PgDn diff · r refresh · Esc close"),
- rows[2],
- );
- let columns = Layout::default()
- .direction(Direction::Horizontal)
- .constraints([Constraint::Percentage(34), Constraint::Percentage(66)])
- .split(rows[1]);
- let items = state.entries.iter().map(|entry| {
- let source = if entry.status.is_some() && entry.session_diff.is_some() {
- "both"
- } else if entry.status.is_some() {
- "git"
- } else {
- "session"
- };
- ListItem::new(format!("{} [{source}]", entry.path))
- });
- let mut selection =
- ListState::default().with_selected((!state.entries.is_empty()).then_some(state.selected));
- frame.render_stateful_widget(
- List::new(items)
- .block(Block::default().borders(Borders::ALL).title("Files"))
- .highlight_style(theme::on_accent_style())
- .highlight_symbol("› "),
- columns[0],
- &mut selection,
- );
- let detail = columns[1].inner(Margin { vertical: 1, horizontal: 1 });
- let height = usize::from(detail.height);
- let selected = state.entries.get(state.selected);
- let lines: Vec> = if let Some(entry) = selected {
- if let Some(raw) = &entry.working_diff {
- diff::render_raw_unified_diff_window(raw, state.scroll, height)
- } else if let Some(session) = &entry.session_diff {
- let refresh = state
- .rendered_session_diff
- .as_ref()
- .is_none_or(|(index, width, _)| *index != state.selected || *width != detail.width);
- if refresh {
- state.rendered_session_diff =
- Some((state.selected, detail.width, diff::render_diff(session, detail.width)));
- }
- state.rendered_session_diff.as_ref().map_or_else(Vec::new, |(_, _, lines)| {
- lines.iter().skip(state.scroll).take(height).cloned().collect()
- })
- } else {
- Vec::new()
- }
- } else if state.loading {
- vec![Line::from("Loading Git changes...")]
- } else {
- vec![Line::from("No changes found in the retained session tool diffs or working tree.")]
- };
- let title = selected.map_or("Diff".to_owned(), |entry| {
- format!(
- "{}{}",
- entry.path,
- entry.status.as_ref().map_or(String::new(), |status| format!(" [{status}]"))
- )
- });
- frame.render_widget(
- Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(title)),
- columns[1],
- );
- if let Some(error) = &state.error {
- frame.render_widget(
- Paragraph::new(error.as_str()).style(Style::default().fg(theme::warning())),
- rows[2],
- );
- }
-}
diff --git a/src/ui/document_table.rs b/src/ui/document_table.rs
index d4df3441..d77012e8 100644
--- a/src/ui/document_table.rs
+++ b/src/ui/document_table.rs
@@ -132,7 +132,7 @@ pub fn render_markdown_with_tables(
} else {
chunk
};
- out.extend(markdown::render_markdown_safe(&chunk, bg));
+ out.extend(markdown::render_markdown_at_width(&chunk, width, bg));
}
MarkdownBlock::Table(table) => {
if out.last().is_some_and(|line| !line_is_blank(line)) {
@@ -173,7 +173,8 @@ fn split_markdown_blocks(text: &str) -> Vec {
while let Some((event, range)) = parser.next() {
match event {
- Event::Start(Tag::Table(alignments)) => {
+ // Nested tables belong to their container's layout and available width.
+ Event::Start(Tag::Table(alignments)) if depth == 0 => {
if text_start < range.start {
blocks.push(MarkdownBlock::Text(text[text_start..range.start].to_owned()));
}
diff --git a/src/ui/footer_rows.rs b/src/ui/footer_rows.rs
index f496465d..400bfd71 100644
--- a/src/ui/footer_rows.rs
+++ b/src/ui/footer_rows.rs
@@ -3,7 +3,8 @@
use crate::agent::model;
use crate::app::agents::SubagentStatus;
-use crate::app::{App, MessageBlock};
+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};
@@ -13,28 +14,30 @@ use ratatui::widgets::{Paragraph, Widget};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
const FOOTER_COLUMN_GAP: u16 = 1;
-const MIN_CONTEXT_LOCATION_WIDTH: usize = 10;
-const MIN_CONTEXT_BRANCH_WIDTH: usize = 4;
+const FIELD_SEPARATOR: &str = " \u{b7} ";
/// Ctrl+T lists the agents (Enter there inspects one); Left Arrow on an empty
/// composer opens the `/agents` inspector directly.
-const SHELF_HINT: &str = "ctrl+t list · ← inspect";
+const SHELF_HINT: &str = "ctrl+t list \u{b7} \u{2190} inspect";
fn footer_context_value() -> Color {
theme::secondary()
}
type FooterItem = Option<(String, Color)>;
+/// The plan line and the agent shelf (when there is one), the configured status row, then
+/// the user's `statusLine` command output (when set).
pub(crate) struct SerializedFooterRows {
pub rows: Vec>,
}
pub(crate) fn serialize_footer_rows(app: &App, total_width: u16) -> SerializedFooterRows {
+ let layout = FooterLayout::from_saved(app.config.saved_value(SETTING_ID)).unwrap_or_default();
let mut rows = Vec::new();
if let Some(plan) = build_plan_line(app, total_width) {
rows.push(plan);
}
rows.extend(build_agent_shelf(app, total_width));
- rows.extend(base_rows(app, total_width));
+ rows.push(serialize_footer(app, &layout, total_width));
if let Some(status_line) = app.footer.status_line.as_ref() {
rows.push(compose_footer_row(
Line::from(Span::styled(
@@ -107,429 +110,229 @@ fn build_agent_shelf(app: &App, width: u16) -> Vec> {
rows
}
-fn gauge_color(percent: f64) -> Color {
- if percent >= 90.0 {
- theme::danger()
- } else if percent >= 70.0 {
- theme::warning()
- } else {
- footer_context_value()
- }
-}
-
-fn gauges(app: &App) -> Vec> {
- let mut result = Vec::new();
- if let Some(percent) = app.session_runtime.session_usage.context_usage_percent {
- let filled = (usize::from(percent.min(100)) * 8).div_ceil(100).min(8);
- result.push(Line::from(vec![
- Span::styled("ctx ", Style::default().fg(theme::tertiary())),
- Span::styled(
- format!("{}{} {percent}%", "▓".repeat(filled), "░".repeat(8 - filled)),
- Style::default().fg(gauge_color(f64::from(percent))),
- ),
- ]));
- }
- let snapshot =
- app.usage.snapshot.as_ref().filter(|s| s.source == crate::app::UsageSourceKind::Sdk);
- for (label, window) in [
- ("5h", snapshot.and_then(|s| s.five_hour.as_ref())),
- ("7d", snapshot.and_then(|s| s.seven_day.as_ref())),
- ] {
- if let Some(window) = window {
- result.push(rate_gauge(label, window.utilization));
+/// 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);
}
}
- if result.len()
- == usize::from(app.session_runtime.session_usage.context_usage_percent.is_some())
- && let Some(update) = app.session_runtime.last_rate_limit_update.as_ref()
- && let (Some(kind), Some(value)) = (update.rate_limit_type.as_deref(), update.utilization)
- {
- match kind {
- "five_hour" => result.push(rate_gauge("5h", value * 100.0)),
- "seven_day" => result.push(rate_gauge("7d", value * 100.0)),
- _ => {}
- }
+ // Earlier entries take precedence; groups never overlap or wrap.
+ while fields_width(&fields, notice_width) > width {
+ fields.pop();
}
- let agents = crate::ui::agent_rows::session_agent_rows(app);
- if !agents.is_empty() && !agents.iter().any(|a| a.status.is_running()) {
- let failed = agents.iter().filter(|a| a.status == SubagentStatus::Failed).count();
- let mut spans = vec![Span::styled(
- format!("{} agents", agents.len()),
- Style::default().fg(theme::tertiary()),
- )];
- if failed > 0 {
- spans.push(Span::styled(
- format!(" · {failed} failed"),
- Style::default().fg(theme::danger()),
- ));
+ 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::tertiary())));
}
- result.push(Line::from(spans));
+ right.push(Span::styled(text, Style::default().fg(color)));
+ }
+ 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);
}
- result
+ Line::from(left)
}
-fn rate_gauge(label: &str, percent: f64) -> Line<'static> {
- Line::from(vec![
- Span::styled(format!("{label} "), Style::default().fg(theme::tertiary())),
- Span::styled(format!("{percent:.0}%"), Style::default().fg(gauge_color(percent))),
- ])
+struct RenderedField {
+ id: FooterFieldId,
+ side: FooterSide,
+ text: String,
+ color: Color,
}
-fn alerts(app: &App, short: bool) -> Line<'static> {
- let permissions = pending_permission_request_count(app);
- let auth = mcp_needs_auth_count(app);
- let mut items = Vec::new();
- if permissions > 0 {
- items.push(Line::from(Span::styled(
- if short {
- format!("{permissions} perm")
- } else {
- format!(
- "{permissions} permission{} waiting",
- if permissions == 1 { "" } else { "s" }
- )
- },
- Style::default().fg(theme::warning()),
- )));
- }
- if auth > 0 {
- items.push(Line::from(Span::styled(
- if short {
- format!("{auth} MCP auth")
- } else {
- format!("{auth} MCP server{} need auth", if auth == 1 { "" } else { "s" })
- },
- Style::default().fg(theme::warning()),
- )));
- }
- join_items(items)
+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 join_items(items: impl IntoIterator
- >) -> Line<'static> {
+fn group_spans(fields: &[RenderedField], side: FooterSide) -> Vec> {
let mut spans = Vec::new();
- for item in items {
- if item.width() == 0 {
- continue;
- }
+ for field in fields.iter().filter(|field| field.side == side) {
if !spans.is_empty() {
- spans.push(Span::raw(" "));
+ spans.push(Span::styled(FIELD_SEPARATOR, Style::default().fg(theme::tertiary())));
}
- spans.extend(item.spans);
+ spans.push(Span::styled(field.text.clone(), Style::default().fg(field.color)));
}
- Line::from(spans)
+ spans
}
-fn pair(left: Line<'static>, right: Line<'static>, width: usize) -> Line<'static> {
- if right.width() == 0 {
- return left;
- }
- let mut spans = left.spans;
- let used: usize = spans.iter().map(|s| s.content.width()).sum();
- spans.push(Span::raw(" ".repeat(width.saturating_sub(used + right.width()))));
- spans.extend(right.spans);
- Line::from(spans)
-}
-
-fn base_rows(app: &App, width: u16) -> Vec> {
- let width = usize::from(width);
- let mut identity = identity_line(app, usize::MAX, true, false);
- let location = build_context_line(app, usize::MAX);
- let mut gauge_items = gauges(app);
- let right = join_items([alerts(app, false), location.clone(), join_items(gauge_items.clone())]);
- if identity.width() + right.width() + 3 <= width {
- return vec![pair(identity, right, width)];
- }
- let mut alerts = alerts(app, false);
- if join_items(gauge_items.clone()).width() + alerts.width() + 3 > width {
- alerts = self::alerts(app, true);
- }
- for label in ["7d ", "5h "] {
- if join_items(gauge_items.clone()).width() + alerts.width() + 3 <= width {
- break;
+fn format_field(app: &App, id: FooterFieldId) -> FooterItem {
+ let gray = footer_context_value();
+ match id {
+ FooterFieldId::SessionTitle => app
+ .session_runtime
+ .session_title
+ .as_ref()
+ .filter(|title| !title.is_empty())
+ .map(|title| (title.clone(), app.session_runtime.accent)),
+ 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))
}
- gauge_items.retain(|item| {
- !item.spans.first().is_some_and(|s| s.content.starts_with(label))
- || item.spans.iter().any(|s| s.style.fg == Some(theme::danger()))
- });
- }
- if join_items(gauge_items.clone()).width() + alerts.width() + 3 > width {
- gauge_items.retain(|item| {
- !item.spans.first().is_some_and(|span| span.content.contains(" agents"))
- });
- }
- let mut gauges = join_items(gauge_items.clone());
- if gauges.width() + alerts.width() + 3 > width {
- gauges = Line::from(
- gauges
- .spans
- .into_iter()
- .map(|mut span| {
- if span.content.contains('▓') || span.content.contains('░') {
- span.content =
- span.content.split_whitespace().last().unwrap_or("").to_owned().into();
- }
- span
- })
- .collect::>(),
- );
- }
- if gauges.width() + alerts.width() + 3 > width {
- for label in ["7d ", "5h "] {
- gauge_items.retain(|item| {
- !item.spans.first().is_some_and(|span| span.content.starts_with(label))
- });
- gauges = join_items(gauge_items.clone());
- if gauges.width() + alerts.width() + 3 <= width {
- break;
- }
+ 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)), gauge_color(f64::from(used)))
+ })
}
+ FooterFieldId::RateLimits => rate_limits_field(app),
+ FooterFieldId::Agents => finished_agents_field(app),
}
- let mut location = build_context_line(app, width.saturating_sub(identity.width() + 3));
- if location.width() < MIN_CONTEXT_LOCATION_WIDTH {
- location = Line::default();
- }
- if identity.width() + location.width() + 3 > width {
- location = Line::default();
- }
- if identity.width() > width {
- identity = identity_line(app, 12, true, false);
- }
- if identity.width() > width {
- identity = identity_line(app, 12, false, false);
- }
- if identity.width() > width {
- let fixed = identity_line(app, 0, false, true).width();
- identity = identity_line(app, width.saturating_sub(fixed + 2).min(12), false, true);
- }
- vec![pair(identity, location, width), pair(gauges, alerts, width)]
}
-fn build_plan_line(app: &App, width: u16) -> Option> {
- let task_steps = app
- .sdk_inventory
- .tasks
- .iter()
- .filter(|task| crate::app::tasks::is_user_plan_task(task))
- .map(|task| (task.status, task.active_form.as_deref().unwrap_or(&task.subject)))
- .collect::>();
- let steps = match app.footer.plan_source {
- crate::app::PlanSource::Todo => app
- .footer
- .todo_plan
- .iter()
- .map(|item| (item.status, item.active_form.as_deref().unwrap_or(&item.content)))
- .collect(),
- crate::app::PlanSource::None if task_steps.is_empty() => app
- .footer
- .todo_plan
- .iter()
- .map(|item| (item.status, item.active_form.as_deref().unwrap_or(&item.content)))
- .collect(),
- crate::app::PlanSource::None | crate::app::PlanSource::Tasks => task_steps,
- };
- if steps.is_empty() {
- return None;
+/// Used share of the five-hour and seven-day windows, from the SDK usage snapshot, or from
+/// the latest rate-limit event while no snapshot has arrived. Colored by the fuller window.
+fn rate_limits_field(app: &App) -> FooterItem {
+ let snapshot =
+ app.usage.snapshot.as_ref().filter(|s| s.source == crate::app::UsageSourceKind::Sdk);
+ let mut windows: Vec<(&str, f64)> = [
+ ("5h", snapshot.and_then(|s| s.five_hour.as_ref())),
+ ("7d", snapshot.and_then(|s| s.seven_day.as_ref())),
+ ]
+ .into_iter()
+ .filter_map(|(label, window)| window.map(|window| (label, window.utilization)))
+ .collect();
+ if windows.is_empty()
+ && let Some(update) = app.session_runtime.last_rate_limit_update.as_ref()
+ && let (Some(kind), Some(value)) = (update.rate_limit_type.as_deref(), update.utilization)
+ {
+ match kind {
+ "five_hour" => windows.push(("5h", value * 100.0)),
+ "seven_day" => windows.push(("7d", value * 100.0)),
+ _ => {}
+ }
}
- let completed =
- steps.iter().filter(|(status, _)| *status == model::TaskStatus::Completed).count();
- let active = steps
+ let fullest = windows
.iter()
- .find(|(status, _)| *status == model::TaskStatus::InProgress)
- .or_else(|| steps.iter().find(|(status, _)| *status == model::TaskStatus::Pending));
- let cells = steps.len().min(8);
- let filled = (completed * cells + steps.len() / 2) / steps.len();
- let meter = format!("{}{}", "▓".repeat(filled), "░".repeat(cells - filled));
- let label = active.map_or("Done", |(_, label)| *label);
- Some(compose_footer_row(
- Line::from(Span::styled(
- format!("{meter} {completed}/{} {label}", steps.len()),
- Style::default().fg(app.session_runtime.accent),
- )),
- None,
- width,
- 0,
- ))
+ .map(|(_, percent)| *percent)
+ .fold(None, |max: Option, p| Some(max.map_or(p, |max| max.max(p))))?;
+ let text = windows
+ .iter()
+ .map(|(label, percent)| format!("{label} {percent:.0}%"))
+ .collect::>()
+ .join(" ");
+ Some((format!("Usage: {text}"), gauge_color(fullest)))
}
-fn footer_row_widths(
- total_width: u16,
- right_text: Option<&str>,
- left_min_width: u16,
-) -> (u16, u16) {
- if total_width == 0 {
- return (0, 0);
- }
-
- 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 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);
+/// The agents of the session once none is running; while one runs the shelf shows them.
+fn finished_agents_field(app: &App) -> FooterItem {
+ let agents = crate::ui::agent_rows::session_agent_rows(app);
+ if agents.is_empty() || agents.iter().any(|agent| agent.status.is_running()) {
+ return None;
}
-
- let left_width = total_width.saturating_sub(right_width).saturating_sub(FOOTER_COLUMN_GAP);
- (left_width, right_width)
+ let failed = agents.iter().filter(|agent| agent.status == SubagentStatus::Failed).count();
+ let count = agents.len();
+ let noun = if count == 1 { "agent" } else { "agents" };
+ Some(if failed > 0 {
+ (format!("{count} {noun}, {failed} failed"), theme::danger())
+ } else {
+ (format!("{count} {noun}"), theme::tertiary())
+ })
}
-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);
+fn gauge_color(percent: f64) -> Color {
+ if percent >= 90.0 {
+ theme::danger()
+ } else if percent >= 70.0 {
+ theme::warning()
+ } else {
+ footer_context_value()
}
-
- buffer_row_to_line(&buf, area, 0)
}
-fn identity_line(app: &App, title_width: usize, effort: bool, compact: bool) -> Line<'static> {
- use crate::ui::agent_rows::clip;
- let mut items = Vec::new();
- if let Some(title) = app.session_runtime.session_title.as_ref().filter(|s| !s.is_empty()) {
- items.push(Line::from(Span::styled(
- clip(title, title_width),
- Style::default().fg(app.session_runtime.accent).bold(),
- )));
- }
- if let Some(model) = app.session_runtime.current_model.as_ref() {
- let mut spans = vec![Span::styled(
- if compact {
- clip(&model.display_name_short, 12)
- } else {
- model.display_name_short.clone()
- },
- Style::default().fg(footer_context_value()),
- )];
- if effort
- && model.supports_effort
- && let Some(level) = app.session_effort()
- {
- spans.push(Span::styled(
- format!(" {}", footer_effort_label(level)),
- Style::default().fg(theme::tertiary()),
- ));
- }
- items.push(Line::from(spans));
- }
- if app.session_runtime.ultracode.is_some_and(model::UltracodeState::effective) {
- items.push(Line::from(Span::styled(
- if compact { "Ultra" } else { "Ultracode" },
- Style::default().fg(theme::ultracode_accent()),
- )));
- }
- if let Some(mode) = app.session_runtime.mode.as_ref().filter(|m| m.current_mode_id != "default")
- {
- items.push(Line::from(Span::styled(
- if compact {
- let label = match mode.current_mode_id.as_str() {
- "bypassPermissions" => "Bypass",
- "acceptEdits" => "Accept",
- "dontAsk" => "Don't ask",
- "auto" => "Auto",
- "plan" => "Plan",
- _ => &mode.current_mode_name,
- };
- clip(label, 8)
- } else {
- mode.current_mode_name.clone()
- },
- Style::default().fg(mode_color(&mode.current_mode_id)),
- )));
- }
- match app.session_runtime.fast_mode_state {
- model::FastModeState::On => {
- items.push(Line::from(Span::styled("fast", Style::default().fg(theme::accent()))));
- }
- model::FastModeState::Cooldown => items.push(Line::from(Span::styled(
- if compact { "cooldown" } else { "fast cooldown" },
- Style::default().fg(theme::warning()),
- ))),
- _ => {}
+fn footer_notice(app: &App) -> FooterItem {
+ let permissions = pending_permission_request_count(app);
+ if permissions > 0 {
+ return Some((format!("{permissions} PEND. PERM."), theme::warning()));
}
- join_items(items)
+ let auth = mcp_needs_auth_count(app);
+ if auth > 0 && should_show_startup_mcp_hint(app) {
+ return Some((format!("{auth} MCP NEEDS AUTH"), theme::warning()));
+ }
+ 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 {
match effort {
- model::EffortLevel::Low => "low",
- model::EffortLevel::Medium => "med",
- model::EffortLevel::High => "high",
- model::EffortLevel::XHigh => "xhigh",
- model::EffortLevel::Max => "max",
+ model::EffortLevel::Low => "Low",
+ model::EffortLevel::Medium => "Med",
+ model::EffortLevel::High => "High",
+ model::EffortLevel::XHigh => "XHigh",
+ model::EffortLevel::Max => "Max",
}
}
-fn build_context_line(app: &App, max_width: usize) -> Line<'static> {
- let Some((location, branch)) = context_values(app, max_width) else {
- return Line::default();
- };
- let mut spans = vec![Span::styled(location, Style::default().fg(footer_context_value()))];
- if let Some(branch) = branch {
- spans.push(Span::styled(" · ", Style::default().fg(theme::tertiary())));
- spans.push(Span::styled(branch, Style::default().fg(footer_context_value())));
- }
- Line::from(spans)
-}
-
-fn context_values(app: &App, max_width: usize) -> Option<(String, Option)> {
- const LOCATION_LABEL_WIDTH: usize = 0;
- 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;
@@ -661,14 +464,152 @@ fn mcp_needs_auth_count(app: &App) -> usize {
.count()
}
-fn mode_color(mode_id: &str) -> Color {
- match mode_id {
- "default" => theme::tertiary(),
- "auto" | "acceptEdits" => theme::warning(),
- "plan" => theme::info(),
- "bypassPermissions" | "dontAsk" => theme::danger(),
- _ => theme::purple(),
+fn should_show_startup_mcp_hint(app: &App) -> bool {
+ !app.transcript
+ .messages
+ .iter()
+ .any(|message| matches!(message.role, MessageRole::User | MessageRole::Assistant))
+}
+
+fn mode_color(mode_id: &str) -> Color {
+ match mode_id {
+ "default" => theme::tertiary(),
+ "auto" | "acceptEdits" => theme::warning(),
+ "plan" => theme::info(),
+ "bypassPermissions" | "dontAsk" => theme::danger(),
+ _ => theme::purple(),
+ }
+}
+
+fn fast_mode_badge(state: model::FastModeState) -> (&'static str, Color) {
+ match state {
+ model::FastModeState::Unknown => ("FAST:?", theme::tertiary()),
+ model::FastModeState::Off => ("FAST:OFF", theme::tertiary()),
+ model::FastModeState::Cooldown => ("FAST:CD", theme::warning()),
+ model::FastModeState::On => ("FAST:ON", theme::accent()),
+ }
+}
+
+fn startup_status_badge(app: &App) -> Option<(&'static str, Color)> {
+ match app.status {
+ crate::app::AppStatus::Connecting => None,
+ crate::app::AppStatus::Ready => Some(("READY", theme::tertiary())),
+ crate::app::AppStatus::CommandPending
+ | crate::app::AppStatus::Thinking
+ | crate::app::AppStatus::Running => Some(("WORKING", theme::warning())),
+ crate::app::AppStatus::Error => Some(("ERROR", theme::danger())),
+ }
+}
+
+fn build_plan_line(app: &App, width: u16) -> Option> {
+ let task_steps = app
+ .sdk_inventory
+ .tasks
+ .iter()
+ .filter(|task| crate::app::tasks::is_user_plan_task(task))
+ .map(|task| (task.status, task.active_form.as_deref().unwrap_or(&task.subject)))
+ .collect::>();
+ let steps = match app.footer.plan_source {
+ crate::app::PlanSource::Todo => app
+ .footer
+ .todo_plan
+ .iter()
+ .map(|item| (item.status, item.active_form.as_deref().unwrap_or(&item.content)))
+ .collect(),
+ crate::app::PlanSource::None if task_steps.is_empty() => app
+ .footer
+ .todo_plan
+ .iter()
+ .map(|item| (item.status, item.active_form.as_deref().unwrap_or(&item.content)))
+ .collect(),
+ crate::app::PlanSource::None | crate::app::PlanSource::Tasks => task_steps,
+ };
+ if steps.is_empty() {
+ return None;
+ }
+ let completed =
+ steps.iter().filter(|(status, _)| *status == model::TaskStatus::Completed).count();
+ let active = steps
+ .iter()
+ .find(|(status, _)| *status == model::TaskStatus::InProgress)
+ .or_else(|| steps.iter().find(|(status, _)| *status == model::TaskStatus::Pending));
+ let cells = steps.len().min(8);
+ let filled = (completed * cells + steps.len() / 2) / steps.len();
+ let meter = format!("{}{}", "▓".repeat(filled), "░".repeat(cells - filled));
+ let label = active.map_or("Done", |(_, label)| *label);
+ Some(compose_footer_row(
+ Line::from(Span::styled(
+ format!("{meter} {completed}/{} {label}", steps.len()),
+ Style::default().fg(app.session_runtime.accent),
+ )),
+ None,
+ width,
+ 0,
+ ))
+}
+
+fn footer_row_widths(
+ total_width: u16,
+ right_text: Option<&str>,
+ left_min_width: u16,
+) -> (u16, u16) {
+ if total_width == 0 {
+ return (0, 0);
+ }
+
+ 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 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 left_width = total_width.saturating_sub(right_width).saturating_sub(FOOTER_COLUMN_GAP);
+ (left_width, right_width)
+}
+
+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 buffer_row_to_line(buf: &Buffer, area: Rect, row: u16) -> Line<'static> {
@@ -722,39 +663,167 @@ fn buffer_row_to_line(buf: &Buffer, area: Rect, row: u16) -> Line<'static> {
mod tests {
use super::*;
use crate::agent::model::{McpServerConnectionStatus, McpServerStatus};
- use crate::app::{BlockCache, ChatMessage, InlinePermission, ModeState, ToolCallInfo};
+ use crate::app::{
+ AppStatus, BlockCache, ChatMessage, InlinePermission, MessageBlock, MessageRole, ModeState,
+ ToolCallInfo,
+ };
use tokio::sync::oneshot;
- fn text(line: &Line<'_>) -> String {
- line.spans.iter().map(|s| s.content.as_ref()).collect()
+ fn line_text(line: &Line<'_>) -> String {
+ line.spans.iter().map(|span| span.content.as_ref()).collect()
}
- fn fixture() -> App {
+
+ fn app_with_mode() -> App {
let mut app = App::test_default();
- app.cwd = "~/Github/system-config".to_owned();
- app.session_runtime.session_title = Some("system-config@main-2".to_owned());
- app.session_runtime.current_model =
- Some(model::CurrentModel::new("opus", "Opus 5.5", "Opus 5.5").supports_effort(true));
app.session_runtime.mode = Some(ModeState {
- current_mode_id: "auto".to_owned(),
- current_mode_name: "Auto".to_owned(),
- available_modes: vec![],
+ current_mode_id: "default".to_owned(),
+ current_mode_name: "default".to_owned(),
+ available_modes: Vec::new(),
});
- app.session_runtime.session_usage.context_usage_percent = Some(6);
- app.set_git_branch_for_test(Some("main"));
app
}
- fn permission(app: &mut App) {
- let (response_tx, _) = oneshot::channel();
+
+ #[test]
+ fn fit_footer_right_text_truncates_when_needed() {
+ let text = "Update available: v9.9.9 (current v0.2.0)";
+ let fitted = fit_footer_right_text(text, 12).expect("fitted text");
+ assert!(fitted.ends_with("..."));
+ assert!(UnicodeWidthStr::width(fitted.as_str()) <= 12);
+ }
+
+ #[test]
+ fn fit_footer_suffix_text_keeps_path_tail() {
+ let text = "~/work/company/claude_rust";
+ let fitted = fit_footer_suffix_text(text, 14).expect("fitted text");
+ assert!(fitted.starts_with("..."));
+ assert!(fitted.ends_with("claude_rust"));
+ assert!(UnicodeWidthStr::width(fitted.as_str()) <= 14);
+ }
+
+ #[test]
+ fn serialize_footer_rows_produces_exactly_one_row() {
+ let app = App::test_default();
+ let serialized = serialize_footer_rows(&app, 80);
+
+ assert_eq!(serialized.rows.len(), 1);
+ }
+
+ #[test]
+ fn first_row_preserves_mode_and_fast_mode_badges() {
+ let app = app_with_mode();
+ let serialized = serialize_footer_rows(&app, 80);
+ let text = line_text(&serialized.rows[0]);
+
+ assert!(text.contains("[default]"));
+ assert!(text.contains("[FAST:OFF]"));
+ }
+
+ #[test]
+ fn reported_session_effort_updates_the_independent_effort_field() {
+ let mut app = app_with_mode();
+ app.session_runtime.session_id = Some("sess-1".into());
+ app.session_runtime.current_model =
+ Some(model::CurrentModel::new("opus", "Opus", "Opus").supports_effort(true));
+ for (value, label) in [
+ ("low", "Low"),
+ ("medium", "Med"),
+ ("high", "High"),
+ ("xhigh", "XHigh"),
+ ("max", "Max"),
+ ] {
+ crate::app::handle_client_event(
+ &mut app,
+ crate::agent::events::ClientEvent::SessionUpdate {
+ session_id: "sess-1".to_owned(),
+ update: model::SessionUpdate::ConfigOptionUpdate(model::ConfigOptionUpdate {
+ option_id: "effortLevel".to_owned(),
+ value: serde_json::json!(value),
+ }),
+ },
+ );
+ let footer = serialize_footer_rows(&app, 120);
+ assert!(line_text(&footer.rows[0]).contains(&format!("[{label}]")));
+ assert!(line_text(&footer.rows[0]).contains("[Opus]"));
+ }
+ crate::app::handle_client_event(
+ &mut app,
+ crate::agent::events::ClientEvent::SessionUpdate {
+ session_id: "sess-1".to_owned(),
+ update: model::SessionUpdate::ConfigOptionUpdate(model::ConfigOptionUpdate {
+ option_id: "effortLevel".to_owned(),
+ value: serde_json::Value::Null,
+ }),
+ },
+ );
+ assert!(line_text(&serialize_footer_rows(&app, 120).rows[0]).contains("[Opus]"));
+ }
+
+ #[test]
+ fn ultracode_badge_appears_only_for_verified_effective_state() {
+ for state in [
+ None,
+ model::UltracodeState::new(true, false, false),
+ model::UltracodeState::new(false, true, false),
+ model::UltracodeState::new(false, false, false),
+ model::UltracodeState::new(true, true, true),
+ ] {
+ let mut app = app_with_mode();
+ app.session_runtime.ultracode = state;
+ let footer = serialize_footer_rows(&app, 120);
+ assert_eq!(
+ line_text(&footer.rows[0]).contains("Ultracode"),
+ state.is_some_and(model::UltracodeState::effective)
+ );
+ if state.is_some_and(model::UltracodeState::effective) {
+ let label = footer.rows[0]
+ .spans
+ .iter()
+ .find(|span| span.content == "[Ultracode]")
+ .expect("Ultracode label");
+ assert_eq!(label.style.fg, Some(theme::ultracode_accent()));
+ assert!(
+ footer.rows[0].spans.iter().any(|span| span.content.contains("test-model")
+ && span.style.fg == Some(footer_context_value()))
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn first_row_omits_connecting_badge_when_mode_is_unset() {
+ let mut app = App::test_default();
+ app.status = AppStatus::Connecting;
+
+ let serialized = serialize_footer_rows(&app, 80);
+ let text = line_text(&serialized.rows[0]);
+
+ assert!(!text.contains("[CONNECTING]"));
+ assert!(text.contains("[FAST:OFF]"));
+ }
+
+ #[test]
+ fn footer_preserves_directory_when_space_allows() {
+ let app = App::test_default();
+ let serialized = serialize_footer_rows(&app, 160);
+ let text = line_text(&serialized.rows[0]);
+
+ assert!(text.contains("Loc:"));
+ }
+
+ #[test]
+ fn pending_permission_hint_wins_on_first_row() {
+ let mut app = App::test_default();
+ let (response_tx, _response_rx) = oneshot::channel();
app.transcript.messages.push(ChatMessage::new(
- crate::app::MessageRole::Assistant,
+ MessageRole::Assistant,
vec![MessageBlock::ToolCall(Box::new(ToolCallInfo {
- id: "permission".into(),
- source_message_uuids: vec![],
+ id: "perm-1".into(),
+ source_message_uuids: Vec::new(),
title: "Read".into(),
sdk_tool_name: "Read".into(),
raw_input: None,
raw_input_bytes: 0,
- locations: vec![],
+ locations: Vec::new(),
output_metadata: None,
task_metadata: None,
status: model::ToolCallStatus::Pending,
@@ -778,120 +847,175 @@ mod tests {
}))],
None,
));
- app.index_tool_call("permission".into(), 0, 0);
- app.pending_interaction_ids.push("permission".into());
- }
- fn agent(id: &str, status: model::TaskStatus, name: &str, model: &str) -> model::TaskItem {
- let mut task = model::TaskItem::new(id, "Map the footer code", status);
- task.metadata = Some(
- serde_json::json!({"task_type":"local_agent","subagent_type":name,"model_display":model,"summary":"Analyzing render path","usage":{"total_tokens":34000,"tool_uses":12,"duration_ms":62000}}),
- );
- task
+ app.index_tool_call("perm-1".into(), 0, 0);
+ app.pending_interaction_ids.push("perm-1".into());
+
+ let serialized = serialize_footer_rows(&app, 80);
+ let text = line_text(&serialized.rows[0]);
+ assert!(text.ends_with("1 PEND. PERM."));
+ app.config.snapshot = Some(crate::agent::settings::SettingsSnapshot::test_value(
+ SETTING_ID,
+ serde_json::json!([]),
+ ));
+ let text = line_text(&serialize_footer_rows(&app, 80).rows[0]);
+ assert!(text.ends_with("1 PEND. PERM."));
+ assert!(!text.contains("FAST:"));
+ assert!(!text.contains("Loc:"));
}
+
#[test]
- fn responsive_base_rows_keep_unique_facts_and_alerts() {
- let mut app = fixture();
- permission(&mut app);
- let wide = serialize_footer_rows(&app, 200).rows;
- assert_eq!(wide.len(), 1);
- let row = text(&wide[0]);
- for fact in [
- "system-config@main-2",
- "Opus 5.5",
- "Auto",
- "~/Github/system-config · main",
- "ctx ▓░░░░░░░ 6%",
- "1 permission waiting",
- ] {
- assert_eq!(row.matches(fact).count(), 1, "{row}");
- }
- let medium = serialize_footer_rows(&app, 100).rows;
- assert_eq!(medium.len(), 2);
- assert!(text(&medium[0]).contains("Opus 5.5"));
- assert!(text(&medium[0]).contains("system-config"));
- assert!(text(&medium[1]).contains("1 permission waiting"));
- let narrow = serialize_footer_rows(&app, 50).rows;
- assert_eq!(narrow.len(), 2);
- assert!(text(&narrow[0]).contains("Opus 5.5"));
- assert!(text(&narrow[0]).contains("Auto"));
- assert!(text(&narrow[1]).contains("ctx"));
- assert!(text(&narrow[1]).contains("1 permission waiting"));
- for line in narrow {
- assert!(line.width() <= 50, "{}", text(&line));
- }
+ fn mcp_auth_notice_and_context_are_both_visible_when_space_allows() {
+ let mut app = App::test_default();
+ app.session_runtime.session_usage.context_usage_percent = Some(62);
+ app.mcp.servers.push(McpServerStatus {
+ name: "filesystem".to_owned(),
+ status: McpServerConnectionStatus::NeedsAuth,
+ server_info: None,
+ error: None,
+ config: None,
+ scope: None,
+ source: None,
+ tools: Vec::new(),
+ });
+
+ let serialized = serialize_footer_rows(&app, 160);
+ let text = line_text(&serialized.rows[0]);
+ assert!(text.contains("1 MCP NEEDS AUTH"));
+ assert!(text.contains("Context: 38%"));
}
+
#[test]
- fn alerts_coexist_with_context_and_rate_thresholds() {
- let mut app = fixture();
- permission(&mut app);
- app.session_runtime.session_usage.context_usage_percent = Some(90);
+ fn narrow_width_preserves_notice_before_configured_fields() {
+ let mut app = App::test_default();
app.mcp.servers.push(McpServerStatus {
- name: "filesystem".into(),
+ name: "filesystem".to_owned(),
status: McpServerConnectionStatus::NeedsAuth,
server_info: None,
error: None,
config: None,
scope: None,
source: None,
- tools: vec![],
+ tools: Vec::new(),
});
- let rows = serialize_footer_rows(&app, 50).rows;
- assert!(text(&rows[1]).contains("ctx"));
- assert!(text(&rows[1]).contains("1 perm"));
- assert!(text(&rows[1]).contains("1 MCP auth"));
- assert!(
- rows[1]
- .spans
- .iter()
- .any(|s| s.content.contains("90%") && s.style.fg == Some(theme::danger()))
- );
- assert_eq!(gauge_color(69.0), footer_context_value());
- assert_eq!(gauge_color(70.0), theme::warning());
- assert_eq!(gauge_color(89.0), theme::warning());
- assert_eq!(gauge_color(90.0), theme::danger());
+
+ let serialized = serialize_footer_rows(&app, 36);
+ let text = line_text(&serialized.rows[0]);
+ assert!(text.ends_with("1 MCP NEEDS AUTH"));
+ assert!(text.width() <= 36);
+ let layout = FooterLayout::from_saved(Some(&serde_json::json!([
+ {"id":"fastMode","visible":true,"side":"left"}
+ ])))
+ .expect("layout");
+ let exact_fit = line_text(&serialize_footer(&app, &layout, 27));
+ assert!(exact_fit.starts_with("[FAST:OFF]"));
+ assert!(exact_fit.ends_with("1 MCP NEEDS AUTH"));
+ assert_eq!(exact_fit.width(), 27);
}
+
#[test]
- fn defaults_hidden_effective_states_and_effort_shown() {
- let mut app = fixture();
- app.session_runtime.mode.as_mut().expect("mode").current_mode_id = "default".into();
- app.session_runtime.session_id = Some("session".into());
- for (value, label) in [
- ("low", "low"),
- ("medium", "med"),
- ("high", "high"),
- ("xhigh", "xhigh"),
- ("max", "max"),
+ fn directory_and_branch_render_without_each_other() {
+ let mut app = app_with_mode();
+ app.cwd = "~/work/claude_rust".into();
+ app.set_git_branch_for_test(Some("feature/footer"));
+ for (id, expected, hidden) in [
+ ("directory", "Loc: ~/work/claude_rust", "Branch:"),
+ ("gitBranch", "Branch: feature/footer", "Loc:"),
] {
- crate::app::handle_client_event(
- &mut app,
- crate::agent::events::ClientEvent::SessionUpdate {
- session_id: "session".into(),
- update: model::SessionUpdate::ConfigOptionUpdate(model::ConfigOptionUpdate {
- option_id: "effortLevel".into(),
- value: serde_json::json!(value),
- }),
- },
- );
- let rows = serialize_footer_rows(&app, 200).rows;
- let row = text(&rows[0]);
- assert!(row.contains(&format!("Opus 5.5 {label}")));
- assert!(!row.contains("Auto"));
- assert!(!row.contains("fast"));
+ let layout = FooterLayout::from_saved(Some(&serde_json::json!([
+ {"id":id,"visible":true,"side":"left"}
+ ])))
+ .expect("layout");
+ let text = line_text(&serialize_footer(&app, &layout, 80));
+ assert!(text.contains(expected), "{text}");
+ assert!(!text.contains(hidden), "{text}");
}
- for state in [
- None,
- model::UltracodeState::new(true, false, false),
- model::UltracodeState::new(true, true, true),
- ] {
- app.session_runtime.ultracode = state;
- let row = serialize_footer_rows(&app, 200).rows.remove(0);
- assert_eq!(
- text(&row).contains("Ultracode"),
- state.is_some_and(model::UltracodeState::effective)
- );
+ }
+
+ #[test]
+ fn effort_and_ultracode_do_not_require_a_visible_model() {
+ let mut app = app_with_mode();
+ app.session_runtime.current_model =
+ Some(model::CurrentModel::new("opus", "Opus", "Opus").supports_effort(true));
+ app.session_runtime.config_options.insert("effortLevel".into(), serde_json::json!("high"));
+ app.session_runtime.ultracode = model::UltracodeState::new(true, true, true);
+ let layout = FooterLayout::from_saved(Some(&serde_json::json!([
+ {"id":"effort","visible":true,"side":"left"},
+ {"id":"ultracode","visible":true,"side":"right"}
+ ])))
+ .expect("layout");
+ let text = line_text(&serialize_footer(&app, &layout, 80));
+ assert!(text.starts_with("[High]"));
+ assert!(text.ends_with("[Ultracode]"));
+ assert!(!text.contains("Opus"));
+ app.session_runtime.current_model.as_mut().expect("model").supports_effort = false;
+ assert!(!line_text(&serialize_footer(&app, &layout, 80)).contains("High"));
+ }
+
+ #[test]
+ fn configured_groups_preserve_order_and_anchor_to_opposite_edges() {
+ let mut app = app_with_mode();
+ app.set_git_branch_for_test(Some("main"));
+ app.session_runtime.session_usage.context_usage_percent = Some(28);
+ let layout = FooterLayout::from_saved(Some(&serde_json::json!([
+ {"id":"fastMode","visible":true,"side":"left"},
+ {"id":"permissionMode","visible":true,"side":"left"},
+ {"id":"contextRemaining","visible":true,"side":"right"},
+ {"id":"gitBranch","visible":true,"side":"right"}
+ ])))
+ .expect("layout");
+ let text = line_text(&serialize_footer(&app, &layout, 100));
+ assert!(text.starts_with("[FAST:OFF] · [default]"));
+ assert!(text.ends_with("Context: 72% · Branch: main"));
+ assert_eq!(text.width(), 100);
+ }
+
+ #[test]
+ fn unicode_values_never_overlap_or_exceed_the_single_row() {
+ let mut app = app_with_mode();
+ app.cwd = "~/工作/项目/claude_rust".into();
+ app.set_git_branch_for_test(Some("feature/日本語の長いブランチ"));
+ let layout = FooterLayout::default();
+ for width in 0..=160 {
+ let line = serialize_footer(&app, &layout, width);
+ assert!(line.width() <= usize::from(width), "width {width}: {}", line_text(&line));
}
- app.session_runtime.fast_mode_state = model::FastModeState::Cooldown;
- assert!(text(&serialize_footer_rows(&app, 200).rows[0]).contains("fast cooldown"));
+ }
+
+ #[test]
+ fn shortened_branch_retains_part_of_its_name_or_is_omitted() {
+ let mut app = app_with_mode();
+ app.set_git_branch_for_test(Some("feature/configurable-footer"));
+ let layout = FooterLayout::from_saved(Some(&serde_json::json!([
+ {"id":"gitBranch","visible":true,"side":"left"}
+ ])))
+ .expect("layout");
+ assert!(line_text(&serialize_footer(&app, &layout, 11)).is_empty());
+ let shortened = line_text(&serialize_footer(&app, &layout, 12));
+ assert!(shortened.contains("f..."));
+ assert_eq!(shortened.width(), 12);
+ }
+
+ fn fixture() -> App {
+ let mut app = App::test_default();
+ app.cwd = "~/Github/system-config".to_owned();
+ app.session_runtime.session_title = Some("system-config@main-2".to_owned());
+ app.session_runtime.current_model =
+ Some(model::CurrentModel::new("opus", "Opus 5.5", "Opus 5.5").supports_effort(true));
+ app.session_runtime.mode = Some(ModeState {
+ current_mode_id: "auto".to_owned(),
+ current_mode_name: "Auto".to_owned(),
+ available_modes: vec![],
+ });
+ app.session_runtime.session_usage.context_usage_percent = Some(6);
+ app.set_git_branch_for_test(Some("main"));
+ app
+ }
+ fn agent(id: &str, status: model::TaskStatus, name: &str, model: &str) -> model::TaskItem {
+ let mut task = model::TaskItem::new(id, "Map the footer code", status);
+ task.metadata = Some(
+ serde_json::json!({"task_type":"local_agent","subagent_type":name,"model_display":model,"summary":"Analyzing render path","usage":{"total_tokens":34000,"tool_uses":12,"duration_ms":62000}}),
+ );
+ task
}
#[test]
fn shelf_caps_rows_and_leaves_finished_count_in_base() {
@@ -904,28 +1028,28 @@ mod tests {
];
let rows = serialize_footer_rows(&app, 200).rows;
assert_eq!(rows.len(), 5);
- assert!(text(&rows[0]).contains("2 running · 1 done"));
- assert!(text(&rows[0]).contains("ctrl+t list · ← inspect"), "{}", text(&rows[0]));
+ assert!(line_text(&rows[0]).contains("2 running · 1 done"));
+ assert!(line_text(&rows[0]).contains("ctrl+t list · ← inspect"), "{}", line_text(&rows[0]));
for (index, name, model) in [
(1, "Explore", "Sonnet 5.5"),
(2, "implementer", "Opus 5.5"),
(3, "verifier", "Sonnet 5.5"),
] {
- let row = text(&rows[index]);
+ let row = line_text(&rows[index]);
assert!(row.contains(name) && row.contains(model), "{row}");
assert!(row.contains("12 tools 34k 1m02s"));
}
app.chat_render.terminal_height = 23;
let rows = serialize_footer_rows(&app, 200).rows;
assert_eq!(rows.len(), 2);
- assert!(text(&rows[0]).contains("+3 more"));
+ assert!(line_text(&rows[0]).contains("+3 more"));
for task in &mut app.sdk_inventory.tasks {
task.status = model::TaskStatus::Completed;
}
let rows = serialize_footer_rows(&app, 200).rows;
assert_eq!(rows.len(), 1);
- assert!(text(&rows[0]).contains("3 agents"));
- assert!(!text(&rows[0]).contains("ctrl+t"));
+ assert!(line_text(&rows[0]).contains("3 agents"));
+ assert!(!line_text(&rows[0]).contains("ctrl+t"));
}
#[test]
fn plan_precedes_shelf_and_status_command_stays_last() {
@@ -944,11 +1068,13 @@ mod tests {
"Sonnet 5.5",
));
let rows = serialize_footer_rows(&app, 100).rows;
- assert!(text(&rows[0]).contains("0/1 Current step"));
- assert!(text(&rows[1]).contains("agents"));
- assert_eq!(text(rows.last().expect("status")), "owner command output");
+ assert!(line_text(&rows[0]).contains("0/1 Current step"));
+ assert!(line_text(&rows[1]).contains("agents"));
+ assert_eq!(line_text(rows.last().expect("status")), "owner command output");
app.footer.apply_todo_plan(vec![]);
- assert!(!serialize_footer_rows(&app, 100).rows.iter().any(|r| text(r).contains("0/1")));
+ assert!(
+ !serialize_footer_rows(&app, 100).rows.iter().any(|r| line_text(r).contains("0/1"))
+ );
}
#[test]
fn test_backend_preserves_agent_column_associations_under_resize() {
@@ -981,74 +1107,6 @@ mod tests {
}
}
}
- #[test]
- fn critical_windows_finished_agents_and_alerts_fit_narrow_terminal() {
- let mut app = fixture();
- permission(&mut app);
- app.session_runtime.session_usage.context_usage_percent = Some(90);
- app.mcp.servers.push(McpServerStatus {
- name: "filesystem".into(),
- status: McpServerConnectionStatus::NeedsAuth,
- server_info: None,
- error: None,
- config: None,
- scope: None,
- source: None,
- tools: vec![],
- });
- let mut task = agent("failed", model::TaskStatus::Completed, "verifier", "Sonnet 5.5");
- task.metadata.as_mut().expect("metadata")["terminal_status"] = serde_json::json!("failed");
- app.sdk_inventory.tasks.push(task);
- app.session_runtime.last_rate_limit_update = Some(model::RateLimitUpdate {
- status: model::RateLimitStatus::Allowed,
- error_code: None,
- resets_at: None,
- utilization: Some(0.95),
- rate_limit_type: Some("five_hour".into()),
- limit_scope: None,
- overage_status: None,
- overage_resets_at: None,
- overage_disabled_reason: None,
- is_using_overage: None,
- surpassed_threshold: None,
- can_user_purchase_credits: None,
- has_chargeable_saved_payment_method: None,
- });
- app.session_runtime.session_id = Some("session".into());
- crate::app::handle_client_event(&mut app,crate::agent::events::ClientEvent::StructuredUsageReceived {session_id:"session".into(),snapshot:Some(serde_json::from_value(serde_json::json!({"five_hour":{"utilization":95,"resets_at":null},"seven_day":{"utilization":95,"resets_at":null}})).expect("SDK snapshot")),error:None});
- assert!(
- app.usage.snapshot.as_ref().is_some_and(
- |snapshot| snapshot.five_hour.is_some() && snapshot.seven_day.is_some()
- )
- );
- let rows = serialize_footer_rows(&app, 50).rows;
- assert!(rows.iter().all(|row| row.width() <= 50));
- let gauge = text(&rows[1]);
- assert!(gauge.contains("ctx"));
- assert!(gauge.contains("1 perm"));
- assert!(gauge.contains("1 MCP auth"), "{gauge}");
- }
- #[test]
- fn required_model_mode_and_effective_states_fit_fifty_columns() {
- use ratatui::{Terminal, backend::TestBackend};
- let mut app = fixture();
- let mode = app.session_runtime.mode.as_mut().expect("mode");
- mode.current_mode_id = "bypassPermissions".into();
- mode.current_mode_name = "Bypass Permissions".into();
- app.session_runtime.ultracode = model::UltracodeState::new(true, true, true);
- app.session_runtime.fast_mode_state = model::FastModeState::Cooldown;
- let rows = serialize_footer_rows(&app, 50).rows;
- assert!(rows.iter().all(|row| row.width() <= 50), "{rows:?}");
- let mut terminal = Terminal::new(TestBackend::new(50, 2)).expect("terminal");
- terminal
- .draw(|frame| frame.render_widget(Paragraph::new(rows), frame.area()))
- .expect("draw");
- let row: String = (0..50).map(|x| terminal.backend().buffer()[(x, 0)].symbol()).collect();
- for value in ["Opus 5.5", "Bypass", "Ultra", "cooldown"] {
- assert!(row.contains(value), "{row}");
- }
- }
-
#[test]
fn shelf_excludes_agents_from_finished_previous_waves() {
let mut app = fixture();
@@ -1083,31 +1141,58 @@ mod tests {
);
let shelf = build_agent_shelf(&app, 200);
assert_eq!(shelf.len(), 2);
- assert!(text(&shelf[1]).contains("new-worker"));
- assert!(!shelf.iter().any(|row| text(row).contains("old-worker")));
+ assert!(line_text(&shelf[1]).contains("new-worker"));
+ assert!(!shelf.iter().any(|row| line_text(row).contains("old-worker")));
assert_eq!(crate::ui::agent_rows::session_agent_rows(&app).len(), 2);
}
+
#[test]
- fn all_permission_modes_preserve_model_and_state_at_fifty_columns() {
- for (id, name, label) in [
- ("auto", "Auto", "Auto"),
- ("acceptEdits", "Accept Edits", "Accept"),
- ("plan", "Plan", "Plan"),
- ("bypassPermissions", "Bypass Permissions", "Bypass"),
- ("dontAsk", "Don't ask", "Don't"),
- ] {
- let mut app = fixture();
- let mode = app.session_runtime.mode.as_mut().expect("mode");
- mode.current_mode_id = id.into();
- mode.current_mode_name = name.into();
- app.session_runtime.ultracode = model::UltracodeState::new(true, true, true);
- app.session_runtime.fast_mode_state = model::FastModeState::Cooldown;
- let rows = serialize_footer_rows(&app, 50).rows;
- assert!(rows.iter().all(|row| row.width() <= 50), "{id}: {rows:?}");
- let identity = text(&rows[0]);
- assert!(identity.contains("Opus 5.5"));
- assert!(identity.contains(label), "{identity}");
- assert!(identity.contains("cooldown"));
- }
+ fn fork_fields_render_title_usage_windows_and_finished_agents() {
+ let mut app = fixture();
+ app.session_runtime.session_id = Some("session".into());
+ crate::app::handle_client_event(
+ &mut app,
+ crate::agent::events::ClientEvent::StructuredUsageReceived {
+ session_id: "session".into(),
+ snapshot: Some(
+ serde_json::from_value(serde_json::json!({
+ "five_hour": {"utilization": 72, "resets_at": null},
+ "seven_day": {"utilization": 12, "resets_at": null}
+ }))
+ .expect("SDK snapshot"),
+ ),
+ error: None,
+ },
+ );
+ let mut task = agent("failed", model::TaskStatus::Completed, "verifier", "Sonnet 5.5");
+ task.metadata.as_mut().expect("metadata")["terminal_status"] = serde_json::json!("failed");
+ app.sdk_inventory.tasks.push(task);
+ let layout = FooterLayout::from_saved(Some(&serde_json::json!([
+ {"id":"sessionTitle","visible":true,"side":"left"},
+ {"id":"rateLimits","visible":true,"side":"right"},
+ {"id":"agents","visible":true,"side":"right"}
+ ])))
+ .expect("layout");
+ let row = serialize_footer(&app, &layout, 120);
+ let text = line_text(&row);
+ assert!(text.starts_with("system-config@main-2"), "{text}");
+ assert!(text.ends_with("Usage: 5h 72% 7d 12% \u{b7} 1 agent, 1 failed"), "{text}");
+ let title = row.spans.iter().find(|span| span.content == "system-config@main-2");
+ assert_eq!(title.and_then(|span| span.style.fg), Some(app.session_runtime.accent));
+ let usage = row.spans.iter().find(|span| span.content.starts_with("Usage:"));
+ assert_eq!(usage.and_then(|span| span.style.fg), Some(theme::warning()));
+ }
+
+ #[test]
+ fn context_remaining_turns_warning_then_danger_as_the_window_fills() {
+ assert_eq!(gauge_color(69.0), footer_context_value());
+ assert_eq!(gauge_color(70.0), theme::warning());
+ assert_eq!(gauge_color(89.0), theme::warning());
+ assert_eq!(gauge_color(90.0), theme::danger());
+ let mut app = fixture();
+ app.session_runtime.session_usage.context_usage_percent = Some(92);
+ let row = serialize_footer_rows(&app, 200).rows.remove(0);
+ let context = row.spans.iter().find(|span| span.content == "Context: 8%");
+ assert_eq!(context.and_then(|span| span.style.fg), Some(theme::danger()));
}
}
diff --git a/src/ui/git_diff.rs b/src/ui/git_diff.rs
new file mode 100644
index 00000000..c8ea6326
--- /dev/null
+++ b/src/ui/git_diff.rs
@@ -0,0 +1,375 @@
+// 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::{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::tertiary()))
+ .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::accent()).add_modifier(Modifier::BOLD),
+ ),
+ Span::raw(totals),
+ ]),
+ Line::from(Span::styled(
+ state.comparison.description(),
+ Style::default().fg(theme::tertiary()),
+ )),
+ ]),
+ 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::accent() } else { theme::tertiary() }),
+ )
+}
+
+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::accent())
+ .fg(theme::on_accent())
+ .add_modifier(Modifier::BOLD)
+ } else if row.file_index.is_none() {
+ Style::default().fg(theme::tertiary())
+ } 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::danger()))
+ .wrap(Wrap { trim: false }),
+ inner,
+ );
+ return;
+ }
+ if state.loading.is_some() {
+ frame.render_widget(
+ Paragraph::new("Loading...").style(Style::default().fg(theme::tertiary())),
+ 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::tertiary())));
+ 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(theme::on_accent()).bg(theme::accent())
+ } 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/inline_chat_rows.rs b/src/ui/inline_chat_rows.rs
index f35d3354..200ab924 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;
@@ -147,7 +148,7 @@ fn render_welcome_live_rows(
}
let commit_ready = welcome::welcome_block(&app.transcript.messages[msg_idx])
- .is_some_and(|welcome| welcome::welcome_commit_ready(app, welcome, now));
+ .is_some_and(|welcome| welcome::welcome_commit_ready(app, welcome, width, now));
RenderedMessageRows::message(
serialize_welcome_message(app, msg_idx, width, now),
LiveRowBoundary {
@@ -189,7 +190,15 @@ 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::fill()));
+ }
+ } else if let Some(timestamp) = timestamp
&& let Some(MessageRowSegment::Lines { lines }) = rendered.segments.first_mut()
&& let Some(label) = lines.first_mut()
{
@@ -197,8 +206,21 @@ fn render_user_system_live_rows(
.spans
.push(Span::styled(format!(" · {timestamp}"), Style::default().fg(theme::tertiary())));
}
+ 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,
@@ -228,17 +250,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
};
@@ -252,24 +269,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,
@@ -279,12 +281,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) {
@@ -299,10 +301,16 @@ fn render_assistant_live_rows(
});
rendered.rows.extend(wrap_lines_to_physical_rows(
&[Line::from(Span::styled(label, Style::default().fg(theme::tertiary())))],
- 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",
@@ -319,6 +327,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,
@@ -350,7 +381,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(
@@ -359,11 +389,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;
@@ -375,11 +403,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 {
@@ -440,7 +464,7 @@ fn serialize_compact_welcome_entry(
Style::default().fg(theme::accent()).add_modifier(Modifier::BOLD),
));
let mut lines = wrap_lines_to_physical_rows(&[heading], width);
- let ferris = welcome::ferris_frame(welcome::ferris_phase(app, entry, now));
+ 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
}
@@ -547,8 +571,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(
@@ -605,7 +631,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,
@@ -763,14 +789,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), &[]));
}
}
@@ -833,8 +860,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)
}
@@ -869,19 +897,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> {
@@ -959,7 +983,6 @@ fn leading_blank_lines_between(
enum AssistantLabelVisibility {
Hidden,
WithContent,
- WithActivity,
}
struct AssistantRowsRequest<'a> {
@@ -971,8 +994,6 @@ struct AssistantRowsRequest<'a> {
width: u16,
spinner: SpinnerState,
show_thinking: bool,
- label_visibility: AssistantLabelVisibility,
- leading_blank_lines: usize,
has_prior_assistant_content: bool,
}
@@ -984,17 +1005,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,
@@ -1003,12 +1020,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,
@@ -1019,9 +1035,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,
@@ -1035,9 +1051,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,
@@ -1077,7 +1093,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,
),
@@ -1086,7 +1102,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(
@@ -1094,12 +1110,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 {
@@ -1126,88 +1138,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() {
@@ -1251,45 +1181,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::tertiary())));
+ *padding = message_layout::timestamp_row(×tamp, width, None);
}
- rows.extend(wrap_lines_to_physical_rows(&[label], request.width));
}
fn append_rendered_assistant_item(
@@ -1353,7 +1304,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> {
@@ -1458,7 +1409,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),
)])
}
@@ -1475,20 +1426,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));
}
}
}
@@ -1543,7 +1487,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;
@@ -1578,13 +1522,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")];
@@ -1616,6 +1568,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::fill(),
+ "({column}, {row}) at width {width}"
+ );
+ }
+ }
+ assert_eq!(buffer[(1, 1)].symbol(), "❯");
+ assert_eq!(buffer[(1, 1)].fg, crate::ui::theme::tertiary());
+ 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::fill());
+ 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)
}
@@ -1684,8 +1815,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;
@@ -1864,46 +1999,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");
}
}
@@ -1929,41 +2053,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]
@@ -1986,7 +2079,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")));
@@ -2003,7 +2096,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]
@@ -2026,7 +2131,7 @@ mod tests {
assert_eq!(serialized.stable_row_count(), 0);
assert_eq!(
serialized.first_mutable_boundary_kind(),
- Some(LiveRowBoundaryKind::AssistantLabel)
+ Some(LiveRowBoundaryKind::AssistantTool)
);
}
@@ -2074,20 +2179,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();
@@ -2101,11 +2192,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(
@@ -2117,11 +2212,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(
@@ -2133,7 +2232,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]
@@ -2145,10 +2247,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"]
- );
+ let text = line_texts(&rows);
+ 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");
}
/// A `#` that starts a code line is code, not a heading: the shebang and the comment
@@ -2165,13 +2267,14 @@ mod tests {
assert_eq!(
line_texts(&rows),
vec![
- "Claude",
- "Run this:",
"",
- " ▎ bash",
- " ▎ #!/usr/bin/env bash",
- " ▎ # install deps",
- " ▎ npm ci",
+ " ● Run this:",
+ "",
+ " ▎ bash",
+ " ▎ #!/usr/bin/env bash",
+ " ▎ # install deps",
+ " ▎ npm ci",
+ "",
]
);
}
@@ -2189,18 +2292,20 @@ mod tests {
assert_eq!(
line_texts(&rows),
- vec!["Claude", " ▎ md", " ▎ - not a list item", " ▎ ```", " ▎ x = 1"]
+ vec!["", " ● ▎ md", " ▎ - not a list item", " ▎ ```", " ▎ x = 1", ""]
);
let block_bg = crate::ui::highlight::code_background();
assert!(block_bg.is_some());
- for row in &rows[1..] {
+ // Response column: one cell of padding and the two-cell marker, then the block's
+ // two-cell indent; the shading runs from the rail to the column's last cell.
+ for row in &rows[1..rows.len() - 1] {
let cells: Vec<_> = row
.spans
.iter()
.flat_map(|span| span.content.chars().map(move |_| span.style.bg))
.collect();
assert_eq!(cells.len(), 40, "{row:?}");
- assert!(cells[2..].iter().all(|bg| *bg == block_bg), "{row:?}");
+ assert!(cells[5..39].iter().all(|bg| *bg == block_bg), "{row:?}");
}
let label = rows[1].spans.iter().find(|span| span.content == "md");
assert!(
@@ -2221,11 +2326,12 @@ mod tests {
.messages
.push(assistant_text_message("```\na b c d e f g h\nexactly-16-cells\n```"));
- let rows = serialize_live_rows(&mut app, 20);
+ // 24 columns leave a 16-cell code row inside the response column.
+ let rows = serialize_live_rows(&mut app, 24);
assert_eq!(
line_texts(&rows),
- vec!["Claude", " ▎ a b c d e f", " ▎ g h", " ▎ exactly-16-cells"]
+ vec!["", " ● ▎ a b c d e f", " ▎ g h", " ▎ exactly-16-cells", ""]
);
}
@@ -2236,7 +2342,7 @@ mod tests {
let rows = serialize_live_rows(&mut app, 80);
- assert_eq!(line_texts(&rows), vec!["Claude", "Plan", "", "Step one."]);
+ assert_eq!(line_texts(&rows), vec!["", " ● Plan", "", " Step one.", ""]);
let heading = rows[1].spans.iter().find(|span| span.content.contains("Plan"));
assert!(
heading.is_some_and(|span| {
@@ -2247,33 +2353,6 @@ mod tests {
);
}
- #[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")));
- }
-
#[test]
fn excluded_static_assistant_text_prefix_is_not_rendered_into_live_rows() {
let first = TextBlock::from_complete("first paragraph\n\n")
@@ -2295,7 +2374,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()
@@ -2361,11 +2443,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:?}"
);
}
@@ -2394,11 +2476,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");
@@ -2500,7 +2625,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);
@@ -2511,45 +2636,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]
@@ -2561,10 +2682,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());
}
@@ -2597,13 +2716,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,
@@ -2626,15 +2750,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());
@@ -2662,14 +2795,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
@@ -2683,7 +2819,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,
@@ -2700,14 +2836,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),
@@ -2721,8 +2857,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),
@@ -2764,7 +2900,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);
@@ -2780,7 +2917,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);
@@ -2908,7 +3045,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);
}
@@ -3032,6 +3171,31 @@ mod tests {
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.timeline.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();
@@ -3095,7 +3259,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:?}"
@@ -3122,7 +3289,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:?}"
@@ -3190,7 +3360,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 75f60b6a..79226d5d 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 }
}
@@ -224,8 +219,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 7f628ce8..5d9a428c 100644
--- a/src/ui/markdown.rs
+++ b/src/ui/markdown.rs
@@ -1,10 +1,13 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2025 Simon Peter Rothgang
+mod lists;
+
use super::highlight;
use super::theme;
use super::wrap::{hard_wrap_line, line_display_width};
use crate::failure;
+use pulldown_cmark::{Event, Options, Parser, Tag};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
@@ -38,7 +41,42 @@ impl tui_markdown::StyleSheet for ClaudeRsStyleSheet {
}
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>
@@ -79,87 +117,37 @@ fn render_with_tui_markdown(text: &str, bg: Option) -> Vec>
Line::from(owned_spans).style(line_style)
})
.collect();
- normalize_list_indentation(shorten_link_destinations(lines))
-}
-
-fn normalize_list_indentation(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 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>) -> Line<'static> {
- let existing_indent = line.spans.first().map_or(0, |span| {
- span.content.as_ref().chars().take_while(|ch| ch.is_whitespace()).count()
- });
- if let Some(first) = line.spans.first_mut()
- && existing_indent > 0
- {
- let content = first.content.to_string();
- first.content = content.chars().skip(existing_indent).collect::().into();
- }
- let indent = " ".repeat(2 + existing_indent / 2);
- line.spans.insert(0, Span::styled(indent, line.style));
- line
+ shorten_link_destinations(lines)
}
fn shorten_link_destinations(mut lines: Vec>) -> Vec> {
- const MAX_LINK_DESTINATION_WIDTH: usize = 48;
-
for line in &mut lines {
for span_idx in 1..line.spans.len() {
let is_destination = line.spans[span_idx - 1].content.as_ref() == " (";
if !is_destination {
continue;
}
- let span = &mut line.spans[span_idx];
- let content = span.content.as_ref();
- if content.starts_with("http://") || content.starts_with("https://") {
- let shortened = shorten_link_destination(content, MAX_LINK_DESTINATION_WIDTH);
- span.content = shortened.into();
- span.style = span.style.fg(theme::tertiary()).remove_modifier(Modifier::UNDERLINED);
- }
+ let span = &line.spans[span_idx];
+ line.spans[span_idx] = link_destination_span(span.content.as_ref(), span.style);
}
}
lines
}
+/// A web link's destination after its label: dimmed, not underlined, and shortened to
+/// `MAX_LINK_DESTINATION_WIDTH`. Other destinations keep their text and style.
+fn link_destination_span(destination: &str, style: Style) -> Span<'static> {
+ const MAX_LINK_DESTINATION_WIDTH: usize = 48;
+ if destination.starts_with("http://") || destination.starts_with("https://") {
+ Span::styled(
+ shorten_link_destination(destination, MAX_LINK_DESTINATION_WIDTH),
+ style.fg(theme::tertiary()).remove_modifier(Modifier::UNDERLINED),
+ )
+ } else {
+ Span::styled(destination.to_owned(), style)
+ }
+}
+
fn shorten_link_destination(destination: &str, width: usize) -> String {
if line_display_width(&Line::from(destination)) <= width {
return destination.to_owned();
@@ -179,15 +167,6 @@ fn shorten_link_destination(destination: &str, width: usize) -> String {
shortened
}
-#[cfg(test)]
-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()
-}
-
/// Renders a code block as indented rows on the highlight theme's background, behind a dim
/// rail, with the fence's language as a label row. The code itself is shown exactly as
/// written (tabs expanded) and wrapped at the cell, never at a word.
@@ -241,6 +220,13 @@ 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()
+ }
+
+ fn line_is_blank(line: &Line<'_>) -> bool {
+ line.spans.iter().all(|span| span.content.trim().is_empty())
+ }
use super::*;
use std::panic::catch_unwind;
@@ -248,6 +234,162 @@ 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, false);
+ 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");
+ // Code is drawn as a code block, whose own indent and rail are not list padding.
+ let content = content.strip_prefix(" \u{258e} ").unwrap_or(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),
+ false,
+ );
+ 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();
+ let code_column = code.chars().position(|ch| ch == '-');
+ assert_eq!(code_column, Some(9), "code marker must stay literal, after the code rail");
+ let indented = rows.iter().find(|row| row.contains("indented code")).unwrap();
+ let indented_column =
+ indented.find("indented code").map(|byte| indented[..byte].chars().count());
+ assert_eq!(
+ indented_column,
+ Some(13),
+ "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 = [
@@ -284,14 +426,14 @@ mod tests {
}
#[test]
- fn unordered_lists_keep_blank_rows_between_document_blocks() {
+ 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"]);
}
#[test]
- fn ordered_lists_keep_blank_rows_between_document_blocks() {
+ 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"]);
@@ -304,26 +446,6 @@ mod tests {
assert_eq!(rendered_text(&lines), vec![" - Parent", " - Child", " - Grandchild"]);
}
- #[test]
- fn wrapped_nested_list_continuations_keep_the_compact_indent() {
- let lines = render_markdown_safe(
- "- Parent\n - nested list item that wraps to another physical row",
- None,
- );
- let rows = crate::ui::wrap::wrap_markdown_lines_to_physical_rows(&lines, 24);
-
- assert_eq!(
- rendered_text(&rows),
- vec![
- " - Parent",
- " - nested list item",
- " that wraps to",
- " another physical",
- " row"
- ]
- );
- }
-
#[test]
fn task_lists_keep_markers_after_indentation() {
let lines = render_markdown_safe("- [ ] Todo\n- [x] Done", None);
@@ -385,4 +507,19 @@ mod tests {
assert!(rendered_text(&lines).join("\n").contains(url));
}
+
+ #[test]
+ fn list_links_are_shortened_like_paragraph_links() {
+ let lines = render_markdown_safe(
+ "- see [documentation](https://example.com/a/very/long/path/that/should/not/take/the/whole/terminal)",
+ None,
+ );
+ let destination = lines
+ .iter()
+ .flat_map(|line| &line.spans)
+ .find(|span| span.content.starts_with("https://"))
+ .expect("rendered link destination");
+ assert!(destination.content.ends_with('\u{2026}'));
+ assert_eq!(destination.style.fg, Some(theme::tertiary()));
+ }
}
diff --git a/src/ui/markdown/lists.rs b/src/ui/markdown/lists.rs
new file mode 100644
index 00000000..73ec0339
--- /dev/null
+++ b/src/ui/markdown/lists.rs
@@ -0,0 +1,430 @@
+// 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::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, inner_width(width, prefix)),
+ 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, super::ClaudeRsStyleSheet.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, inner_width(width, &body_prefix));
+ 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 lines = super::super::document_table::render_markdown_with_tables(
+ &table,
+ inner_width(width, &body_prefix),
+ bg,
+ false,
+ );
+ 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(super::ClaudeRsStyleSheet.list_marker());
+ }
+ }
+}
+
+fn inner_width(width: u16, prefix: &str) -> u16 {
+ width.saturating_sub(u16::try_from(display_width(prefix)).unwrap_or(u16::MAX))
+}
+
+/// Code inside a list or quote is drawn like top-level code, at the width its parent leaves.
+fn code_lines(kind: &CodeBlockKind<'_>, content: &[Node<'_>], width: u16) -> 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) => {
+ // An info string can carry attributes: `rust,ignore`, `py title=x`.
+ info.split(|ch: char| ch.is_whitespace() || ch == ',').find(|word| !word.is_empty())
+ }
+ CodeBlockKind::Indented => None,
+ };
+ super::render_code_block(&text, language, width, None)
+}
+
+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 = super::ClaudeRsStyleSheet;
+ 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(" (", style));
+ spans.push(super::link_destination_span(dest_url, style));
+ spans.push(Span::styled(")", 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 {
+ if span.style.bg.is_none() {
+ span.style = span.style.bg(color);
+ }
+ }
+ }
+ line
+ })
+ .collect()
+}
diff --git a/src/ui/message.rs b/src/ui/message.rs
index 932c18df..3ee0bcca 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;
@@ -86,8 +86,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()
{
@@ -101,7 +101,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() {
@@ -144,6 +144,28 @@ mod tests {
use crate::app::{ChatMessage, MessageBlock, MessageRole, SystemSeverity, TextBlock};
use pretty_assertions::assert_eq;
+ #[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);
+ // Code sits behind its block's indent, rail and gutter: four columns past the item.
+ for (text, column) in [("Child heading", 5), ("# literal code", 9), ("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).map(|byte| row[..byte].chars().count()),
+ Some(column),
+ "nested message content lost its owner: {row:?}"
+ );
+ }
+ }
+
// force_markdown_line_breaks
#[test]
@@ -221,38 +243,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::fill()))
- );
}
}
}
@@ -270,33 +294,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]
@@ -316,7 +315,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..632e046b
--- /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::tertiary()));
+ 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::tertiary())));
+ layout.row(content, None, background)
+}
diff --git a/src/ui/message_rows.rs b/src/ui/message_rows.rs
index 8c5d8c2a..699a7d12 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::PROMPT_CHAR, theme::tertiary())),
+ Some(theme::fill()),
+ (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, false);
- 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::tertiary()).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::tertiary()),
@@ -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 7cd78c03..3d17af80 100644
--- a/src/ui/mod.rs
+++ b/src/ui/mod.rs
@@ -9,9 +9,9 @@ mod autocomplete;
mod config;
mod copy;
mod diff;
-mod diff_view;
mod document_table;
pub(crate) mod footer_rows;
+mod git_diff;
pub(crate) mod help;
pub(crate) mod highlight;
pub(crate) mod host_tips;
@@ -21,6 +21,7 @@ pub(crate) mod input_rows;
pub(crate) mod live_rows;
mod markdown;
mod message;
+pub(crate) mod message_layout;
mod message_rows;
mod rewind_picker;
mod search;
@@ -38,7 +39,7 @@ mod welcome;
mod wrap;
pub use message::SpinnerState;
-pub(crate) use welcome::ferris_hello_live;
+pub(crate) use welcome::{ferris_hello_live, welcome_is_whole_transcript};
use crate::app::App;
use crate::app::{FullscreenView, SurfaceMode};
@@ -57,7 +58,7 @@ pub fn render_fullscreen_surface(frame: &mut Frame, app: &mut App) {
SurfaceMode::Fullscreen(FullscreenView::Tasks) => tasks::render(frame, app),
SurfaceMode::Fullscreen(FullscreenView::Verbose) => verbose::render(frame, app),
SurfaceMode::Fullscreen(FullscreenView::Search) => search::render(frame, app),
- SurfaceMode::Fullscreen(FullscreenView::Diff) => diff_view::render(frame, app),
+ SurfaceMode::Fullscreen(FullscreenView::Diff) => git_diff::render(frame, app),
SurfaceMode::Fullscreen(FullscreenView::Agents) => agents::render(frame, app),
SurfaceMode::Chat => {
debug_assert!(false, "chat is rendered by the inline terminal session");
diff --git a/src/ui/session_picker.rs b/src/ui/session_picker.rs
index 05c931a7..1e49160e 100644
--- a/src/ui/session_picker.rs
+++ b/src/ui/session_picker.rs
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: Apache-2.0
-use crate::app::{App, RecentSessionInfo};
+use crate::app::{App, RecentSessionInfo, SessionPickerMutation};
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Margin, Rect};
use ratatui::style::{Modifier, Style};
@@ -9,21 +9,26 @@ 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 = " ";
+const MANAGE_DESCRIPTION: &str = "The action applies to the selected session ID shown below.";
+/// Sessions at least this large show their size in MiB.
+const MIB: u64 = 1024 * 1024;
-#[allow(clippy::too_many_lines)]
pub fn render(frame: &mut Frame, app: &mut App) {
let area = frame.area();
let selecting_turn = app.session_picker.turn_session_id.is_some();
- let mutation = app.session_picker.mutation.as_deref();
+ let managing = app.session_picker.mutation.is_some();
let outer = Block::default()
.borders(Borders::ALL)
.title(if selecting_turn {
"Fork Before Message"
- } else if mutation.is_some() {
+ } else if managing {
"Manage Session"
} else {
"Resume Session"
@@ -34,19 +39,23 @@ 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 if mutation.is_some() {
- "The action applies to the selected session ID shown below."
+ } else if managing {
+ MANAGE_DESCRIPTION
} else {
SESSION_DESCRIPTION
};
- let description_height = wrapped_line_count(description, inner.width);
+ let description_height =
+ wrapped_line_count(app.session_picker.error.as_deref().unwrap_or(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);
@@ -54,7 +63,7 @@ pub fn render(frame: &mut Frame, app: &mut App) {
Paragraph::new(Line::from(Span::styled(
if selecting_turn {
"Select where the fork should stop"
- } else if mutation.is_some() {
+ } else if managing {
"Edit tag or confirm deletion"
} else {
"Select a session to resume"
@@ -63,54 +72,86 @@ pub fn render(frame: &mut Frame, app: &mut App) {
))),
chunks[0],
);
+ // An error takes the description's place, so no tail of the longer text is left behind.
+ let (description_text, description_color) = app
+ .session_picker
+ .error
+ .as_deref()
+ .map_or((description, theme::tertiary()), |error| (error, theme::danger()));
frame.render_widget(
- Paragraph::new(description)
- .style(Style::default().fg(theme::tertiary()))
+ Paragraph::new(description_text)
+ .style(Style::default().fg(description_color))
.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::accent())),
+ chunks[2],
+ );
+ }
- if let Some(mutation) = mutation {
- let message = match mutation {
- crate::app::SessionPickerMutation::Tag { session_id, draft, request_id, .. } => {
- format!(
- "Session: {session_id}\nTag: {draft}\n{}",
- if request_id.is_some() {
- "Saving..."
- } else {
- "Enter saves; empty tag clears"
- }
- )
- }
- crate::app::SessionPickerMutation::Delete { session_id, request_id, .. } => format!(
- "Delete session {session_id}?\n{}",
- if request_id.is_some() { "Deleting..." } else { "Press y to confirm" }
- ),
- };
- frame.render_widget(Paragraph::new(message).wrap(Wrap { trim: false }), chunks[2]);
+ render_body(frame, chunks[3], app);
+
+ frame.render_widget(
+ Paragraph::new(footer)
+ .style(Style::default().fg(theme::tertiary()))
+ .wrap(Wrap { trim: false }),
+ chunks[4],
+ );
+}
+
+fn mutation_text(mutation: &SessionPickerMutation) -> String {
+ match mutation {
+ SessionPickerMutation::Tag { session_id, draft, request_id, .. } => format!(
+ "Session: {session_id}\nTag: {draft}\n{}",
+ if request_id.is_some() { "Saving..." } else { "Enter saves; empty tag clears" }
+ ),
+ SessionPickerMutation::Delete { session_id, request_id, .. } => format!(
+ "Delete session {session_id}?\n{}",
+ if request_id.is_some() { "Deleting..." } else { "Press y to confirm" }
+ ),
+ }
+}
+
+fn render_body(frame: &mut Frame, area: Rect, app: &mut App) {
+ let selecting_turn = app.session_picker.turn_session_id.is_some();
+ if let Some(mutation) = app.session_picker.mutation.as_deref() {
+ frame.render_widget(
+ Paragraph::new(mutation_text(mutation)).wrap(Wrap { trim: false }),
+ area,
+ );
} else if selecting_turn && app.sdk_inventory.rewind_targets_in_flight {
frame.render_widget(
Paragraph::new("Loading session messages...")
.style(Style::default().fg(theme::tertiary()))
.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::danger()))
.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::tertiary()))
.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)
|| crate::app::session_picker::refresh_is_pending(app)
{
@@ -118,28 +159,21 @@ pub fn render(frame: &mut Frame, app: &mut App) {
Paragraph::new("Loading recent sessions...")
.style(Style::default().fg(theme::tertiary()))
.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::tertiary()))
- .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::tertiary()))
+ .wrap(Wrap { trim: false }),
+ area,
);
} else {
- render_session_list(frame, chunks[2], app);
- }
-
- frame.render_widget(
- Paragraph::new(footer_text(app)).style(Style::default().fg(theme::tertiary())),
- chunks[3],
- );
- if let Some(error) = app.session_picker.error.as_deref() {
- frame.render_widget(
- Paragraph::new(error).style(Style::default().fg(theme::danger())),
- chunks[1],
- );
+ render_session_list(frame, area, app);
}
}
@@ -149,48 +183,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::accent() } else { theme::tertiary() })
+ .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(theme::on_accent()).bg(theme::accent())
- } 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::