feat(coding-agent): add shimmer to running jobs and refine ID display

- Running job labels now shimmer in the TUI to provide a dynamic visual indicator of activity.
- Adjusted cache key to account for shimmer animation, ensuring it updates at 30fps instead of the 12.5fps spinner cadence.
- Suppressed job ID display when the job label is identical to its ID, avoiding redundant information.
This commit is contained in:
can1357
2026-06-12 03:58:17 +02:00
parent 197f374801
commit cfeaee0105
6 changed files with 55 additions and 9 deletions
+2 -1
View File
@@ -304,7 +304,8 @@ mod tests {
// infer the bash grammar so `replace block` / `insert after block`
// works. Previously `Path::extension` returned `None`, leaving block
// ops permanently unresolvable on these files.
let code = "ZSH_COMPDUMP=x\nif [[ -f \"$ZSH_COMPDUMP\" ]]; then\n compinit -C\nelse\n compinit\nfi\n";
let code = "ZSH_COMPDUMP=x\nif [[ -f \"$ZSH_COMPDUMP\" ]]; then\n compinit -C\nelse\n \
compinit\nfi\n";
let span = Some(BlockRange { start_line: 2, end_line: 6 });
assert_eq!(resolve(code, "modules/zsh/zshrc", 2), span);
assert_eq!(resolve(code, ".zshrc", 2), span);
+2
View File
@@ -26,9 +26,11 @@
- `/settings`: Tab now toggles keyboard focus between section headings and the setting rows — while headings are focused, ↑/↓ jump whole sections and Enter/Esc drop back into the rows — instead of cycling tabs. ←/→ still switch tabs everywhere, tabs without sections (e.g. Plugins) keep Tab as tab-switching, and the footer hint follows the focus state.
- Image-generation (Antigravity, xAI, OpenRouter, Gemini) and xAI TTS request failures now throw pi-ai's typed `ProviderHttpError` carrying status and response headers instead of `Object.assign`-patched `Error`s.
- Collapsed bash, ssh, and eval previews now cap the command/code section to a viewport-sized tail window (terminal rows minus a chrome reserve) that renders identically while streaming and after completion, with `ctrl+o` as the only way to uncap. Previously bash/ssh capped the command only while streaming and snapped it fully open the moment the tool finished, and eval never capped cell code at all.
- The `job` renderer now shimmers running-job labels (like the `task` renderer) while the poll block is live, freezing to static once the block seals, and drops the id column when the label repeats it — task jobs label themselves with their agent id, so rows read `⟨task⟩ SessionTree` instead of `SessionTree ⟨task⟩ SessionTree`.
### Fixed
- Fixed session tree connector rendering in `/tree` and the HTML export: flattened chain rows under a last-sibling (`└─`) branch drew a dangling `│` in the corner column itself and shifted columns once the chain branched deeper; the chain anchor now sits one level right, below the branch head's content, so connectors terminate at `└─` and indentation stays stable ([#2325](https://github.com/can1357/oh-my-pi/issues/2325)).
- Fixed prompt and image input typed while the agent is idle between turns from being dropped so steering now queues and auto-resumes processing once the session is ready
- Fixed image-bearing prompts queued during compaction registering their local-submission signature without the image count, so the delivery event treated them as foreign messages — clearing any draft typed since queuing and leaking the stale signature.
- Fixed Esc/Alt+Up queue restoration dropping attached images: `clearQueue()` and `popLastQueuedMessage()` now hand queued images back alongside the text, and the restore paths return them to the pending-image buffer instead of silently discarding them.
@@ -6388,7 +6388,6 @@ export class AgentSession {
const snapcompactResult = await snapcompact.compact(preparation, {
convertToLlm,
model: this.model,
thinkingLevel: this.thinkingLevel,
});
summary = snapcompactResult.summary;
shortSummary = snapcompactResult.shortSummary;
@@ -7922,7 +7921,6 @@ export class AgentSession {
const snapcompactResult = await snapcompact.compact(preparation, {
convertToLlm,
model: this.model,
thinkingLevel: this.thinkingLevel,
});
summary = snapcompactResult.summary;
shortSummary = snapcompactResult.shortSummary;
+25 -5
View File
@@ -5,6 +5,7 @@ import { prompt } from "@oh-my-pi/pi-utils";
import * as z from "zod/v4";
import type { AsyncJob, AsyncJobManager } from "../async";
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { shimmerEnabled, shimmerText } from "../modes/theme/shimmer";
import type { Theme } from "../modes/theme/theme";
import jobDescription from "../prompts/tools/job.md" with { type: "text" };
import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
@@ -493,8 +494,15 @@ export const jobToolRenderer = {
render(width: number): readonly string[] {
const expanded = options.expanded;
const spinnerFrame = options.spinnerFrame ?? 0;
const key = new Hasher().bool(expanded).u32(width).u32(spinnerFrame).digest();
if (cached?.key === key) return cached.lines;
// Running-job labels shimmer while the poll block is live; the band
// phase is Date.now()-sampled at render time, so serving cached bytes
// would pin it to the ~12.5fps spinner-glyph cadence instead of the
// 30fps redraw. Bypass the cache while any row animates, and key on
// the animation state so a sealed block never hits stale shimmered
// bytes (spinnerFrame falls back to 0 on both sides of the seal).
const shimmerActive = counts.running > 0 && options.spinnerFrame !== undefined && shimmerEnabled();
const key = new Hasher().bool(expanded).u32(width).u32(spinnerFrame).bool(shimmerActive).digest();
if (!shimmerActive && cached?.key === key) return cached.lines;
const itemLines = renderTreeList<JobSnapshot>(
{
@@ -513,7 +521,9 @@ export const jobToolRenderer = {
job.status === "running" ? options.spinnerFrame : undefined,
);
const typeBadge = formatBadge(job.type, statusToColor(job.status), uiTheme);
const idText = uiTheme.fg("muted", job.id);
// Task jobs label themselves with their agent id, which is also
// the job id — drop the id column instead of stuttering it twice.
const idPart = job.label.trim() === job.id ? "" : ` ${uiTheme.fg("muted", job.id)}`;
const rawLabelLines = (job.label || "(no label)").split(/\r?\n/);
const maxLabelLines = expanded ? LABEL_LINES_EXPANDED : LABEL_LINES_COLLAPSED;
const visibleLabelLines = rawLabelLines
@@ -524,8 +534,18 @@ export const jobToolRenderer = {
visibleLabelLines[visibleLabelLines.length - 1] = `${last} …`;
}
const durationText = uiTheme.fg("dim", formatDuration(job.durationMs));
const headLabel = uiTheme.fg("toolOutput", visibleLabelLines[0] ?? "");
lines.push(`${icon} ${idText} ${typeBadge} ${headLabel} ${durationText}`);
// Running rows in a live block shimmer their label like the task
// renderer; once the block stops animating (sealed, or a settled
// snapshot — spinnerFrame cleared) they render static so
// scrollback never keeps a mid-sweep shimmer band.
const live = job.status === "running" && options.spinnerFrame !== undefined;
const headRaw = visibleLabelLines[0] ?? "";
const headLabel = live
? shimmerEnabled()
? shimmerText(headRaw, uiTheme)
: uiTheme.fg("accent", headRaw)
: uiTheme.fg("toolOutput", headRaw);
lines.push(`${icon}${idPart} ${typeBadge} ${headLabel} ${durationText}`);
for (let i = 1; i < visibleLabelLines.length; i++) {
lines.push(` ${uiTheme.fg("toolOutput", visibleLabelLines[i]!)}`);
}
@@ -101,4 +101,13 @@ describe("job renderer task-result preview", () => {
const output = renderLines("42 pass, 0 fail (18.4s)");
expect(output).toContain("42 pass, 0 fail (18.4s)");
});
it("drops the id column when the label repeats it", () => {
// Task jobs label themselves with their agent id; rendering both columns
// stutters ("SpawnProbe ⟨task⟩ SpawnProbe").
const output = Bun.stripANSI(renderLines("done"));
const header = output.split("\n").find(line => line.includes("SpawnProbe"));
expect(header).toBeDefined();
expect(header!.match(/SpawnProbe/g)).toHaveLength(1);
});
});
@@ -1,5 +1,7 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test";
import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { LocalProtocolHandler } from "@oh-my-pi/pi-coding-agent/internal-urls/local-protocol";
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
import {
fileHyperlink,
isHyperlinkEnabled,
@@ -247,6 +249,20 @@ describe("urlHyperlinkAlways", () => {
});
describe("tryResolveInternalUrlSync", () => {
// The "no session options" contract below asserts on process-global state
// (AgentRegistry main session, LocalProtocolHandler override) that sibling
// test files in the same worker may have populated. Pin the premise
// explicitly so the test is full-suite safe, not just file-local safe.
beforeEach(() => {
AgentRegistry.resetGlobalForTests();
LocalProtocolHandler.resetOverrideForTests();
});
afterEach(() => {
AgentRegistry.resetGlobalForTests();
LocalProtocolHandler.resetOverrideForTests();
});
it("returns undefined for non-internal URLs", () => {
expect(tryResolveInternalUrlSync("/abs/path/file.ts")).toBeUndefined();
expect(tryResolveInternalUrlSync("relative/path.ts")).toBeUndefined();