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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user