diff --git a/crates/pi-ast/src/block.rs b/crates/pi-ast/src/block.rs index 792f1d5f9..3f3f7d808 100644 --- a/crates/pi-ast/src/block.rs +++ b/crates/pi-ast/src/block.rs @@ -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); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d251a08ac..d1bc1f3b0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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. diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index dc4ebbd2b..c992c627a 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -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; diff --git a/packages/coding-agent/src/tools/job.ts b/packages/coding-agent/src/tools/job.ts index 6bba0de24..5c2d3bbdb 100644 --- a/packages/coding-agent/src/tools/job.ts +++ b/packages/coding-agent/src/tools/job.ts @@ -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( { @@ -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]!)}`); } diff --git a/packages/coding-agent/test/job-renderer-preview.test.ts b/packages/coding-agent/test/job-renderer-preview.test.ts index 03bf8ef54..33e538c37 100644 --- a/packages/coding-agent/test/job-renderer-preview.test.ts +++ b/packages/coding-agent/test/job-renderer-preview.test.ts @@ -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); + }); }); diff --git a/packages/coding-agent/test/tui/hyperlink.test.ts b/packages/coding-agent/test/tui/hyperlink.test.ts index 9a5e0742d..65275e050 100644 --- a/packages/coding-agent/test/tui/hyperlink.test.ts +++ b/packages/coding-agent/test/tui/hyperlink.test.ts @@ -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();