Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions crates/pi-natives/src/path_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ use napi_derive::napi;
use parking_lot::Mutex;
use sha2::{Digest, Sha256};

use crate::task;
/// Classification of a read-only retained-publication observation.
#[napi(object)]
pub struct NativeBrokerPublicationObservation {
Expand Down Expand Up @@ -1013,6 +1014,36 @@ pub fn link_no_replace_path(
))
}

/// Async variant of [`rename_no_replace_path`] scheduled on the libuv blocking
/// pool.
///
/// Managed output publication awaits this boundary so a rename that stalls in
/// the kernel (oversized APFS directory namespaces, issue #4394) blocks one
/// pool thread instead of the agent's event loop: await timeouts, sibling
/// subagents, and watchdogs keep running, and a hung publication degrades to
/// one unresolved receipt rather than a frozen process.
#[napi]
pub fn rename_no_replace_path_async(
source_path: String,
destination_path: String,
) -> task::Promise<NativeNoReplaceResult> {
task::blocking("rename_no_replace_path", (), move |_| {
Ok(rename_no_replace_path(source_path, destination_path))
})
}

/// Async variant of [`link_no_replace_path`] scheduled on the libuv blocking
/// pool; see [`rename_no_replace_path_async`] for the rationale.
#[napi]
pub fn link_no_replace_path_async(
source_path: String,
destination_path: String,
) -> task::Promise<NativeNoReplaceResult> {
task::blocking("link_no_replace_path", (), move |_| {
Ok(link_no_replace_path(source_path, destination_path))
})
}

/// Capture a deterministic, descriptor-relative snapshot of a regular-file and
/// directory-only tree. Symlinks, special files, non-UTF-8 names, and topology
/// changes are rejected rather than followed.
Expand Down
2 changes: 1 addition & 1 deletion docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@ These are read as runtime signals; they are usually set by the terminal/OS rathe
| `GJC_DEBUG_REDRAW` | If `1`, enables redraw debug logging |
| `GJC_TUI_DEBUG` | If `1`, enables deep TUI debug dump path |
| `GJC_FORCE_IMAGE_PROTOCOL` | Forces terminal image protocol detection (`kitty`, `iterm2`/`iterm`, `sixel`, `none`) |
| `GJC_TUI_KEYBOARD_PROTOCOL` | Enhanced keyboard input (Kitty keyboard protocol + xterm modifyOtherKeys). Enabled by default; set `0` / `false` to leave the keyboard in its default mode. Use this when a terminal (e.g. Android Termius) breaks IME/Hangul composition while these enhanced modes are active. |
| `GJC_TUI_KEYBOARD_PROTOCOL` | Enhanced keyboard input (Kitty keyboard protocol + xterm modifyOtherKeys). Enabled by default; set `0` / `false` to leave the keyboard in its default mode. GJC automatically skips the modifyOtherKeys fallback on Windows and Apple Terminal because it breaks CJK/Hangul IME composition there; use the full opt-out for other affected terminals such as Android Termius. |
| `GJC_TUI_SYNCHRONIZED_OUTPUT` | Synchronized-output framing (`CSI ?2026h/l`) is enabled by default. Set `0` / `false` / `off` / `no` before starting or restarting GJC to remove that framing for terminal parsers that render it incorrectly. This is a process-wide compatibility and diagnostic switch, not tmux/Byobu client detection or per-client negotiation. Disabling it may expose visible tearing; return to the default after diagnosis unless the client requires the workaround. |

---
Expand Down
5 changes: 5 additions & 0 deletions docs/theme.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,11 @@ Current defaults from settings schema:
- `symbolPreset = "unicode"`
- `colorBlindMode = false`

### Interactive switching (`/theme`)

- `/theme` with no arguments opens the interactive theme selector with live preview.
- `/theme <name>` switches immediately: the name is validated against built-in and custom themes, persisted to the detected slot (`theme.dark` or `theme.light`), and applied to the running session (status line, editor border, and chat re-render at once). An unknown name is rejected with the list of available themes and changes nothing.

### Explicit switching (`setTheme`)

- loads selected theme
Expand Down
2 changes: 1 addition & 1 deletion docs/tui-runtime-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ A forced render (`requestRender(true)`) resets previous-line caches and cursor b
1. Enables raw mode and bracketed paste.
2. Attaches resize handler.
3. Creates a `StdinBuffer` to split partial escape chunks into complete sequences.
4. Queries Kitty keyboard protocol support (`CSI ? u`), then enables protocol flags if supported; otherwise enables modifyOtherKeys fallback after a short timeout.
4. Queries Kitty keyboard protocol support (`CSI ? u`), then enables protocol flags if supported; otherwise enables the modifyOtherKeys fallback after a short timeout, except on Windows and Apple Terminal where that fallback breaks CJK/Hangul IME composition.
5. Queries OSC 11 background color and enables Mode 2031 appearance notifications for dark/light theme detection.
6. On Windows, attempts VT input enablement via `kernel32` mode flags.
`StdinBuffer` behavior:
Expand Down
11 changes: 7 additions & 4 deletions packages/ai/src/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2396,12 +2396,15 @@ function latestAssistantThinkingIsUnreplayable(messages: Message[], model: Model
if (block.type !== "thinking") return false;
// A block with empty text and no signature cannot go back on the wire:
// `convertAnthropicMessages` drops it, and Anthropic rejects the turn for
// arriving without it. A block with a valid signature is replayable even
// when the text is empty, and non-signing endpoints replay unsigned blocks
// verbatim, so only signing endpoints treat a missing signature as
// unreplayable.
// arriving without it. A block with a valid signature AND non-empty text is
// replayable. But a signed block whose text was emptied — e.g. by
// clear_thinking_20251015 — carries a stale signature that signing endpoints
// reject on replay (issue #4247). Non-signing endpoints replay unsigned
// blocks verbatim, so only they treat a missing signature as unreplayable.
const hasSignature = !!block.thinkingSignature?.trim();
const isEmpty = !block.thinking.trim();
if (!hasSignature) return requiresSignature;
if (isEmpty && requiresSignature) return true;
return false;
});
}
Expand Down
11 changes: 9 additions & 2 deletions packages/ai/src/providers/transform-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,15 @@ export function transformMessages<TApi extends Api>(
if (dropAssistantThinkingForRepair && replaysAsNativeThinking) return [];
if (mustPreserveLatestAnthropicThinking) return sanitized;
// For same model: keep thinking blocks with signatures (needed for replay)
// even if the thinking text is empty (OpenAI encrypted reasoning)
if (isSameModel && sanitized.thinkingSignature) return sanitized;
// even if the thinking text is empty — but only for non-Anthropic APIs where
// the signature represents OpenAI encrypted reasoning. For anthropic-messages,
// a signed block with empty text means clear_thinking_20251015 stripped the
// content server-side while the stale signature remained; replaying it
// produces `thinking ... cannot be modified` 400s on every turn (#4247).
if (isSameModel && sanitized.thinkingSignature) {
if (sanitized.thinking.trim() === "" && model.api === "anthropic-messages") return [];
return sanitized;
}
// Skip empty thinking blocks, convert others to plain text
if (!sanitized.thinking || sanitized.thinking.trim() === "") return [];
if (isSameModel) return sanitized;
Expand Down
51 changes: 44 additions & 7 deletions packages/ai/test/anthropic-unreplayable-thinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@ const user: UserMessage = { role: "user", content: "go", timestamp: Date.now() }
const HOLLOW_THINKING = { type: "thinking" as const, thinking: "", thinkingSignature: "" };
const SIGNED_EARLY = { type: "thinking" as const, thinking: "early reasoning", thinkingSignature: "sig_early" };
const SIGNED_LATE = { type: "thinking" as const, thinking: "late reasoning", thinkingSignature: "sig_late" };
/** A block with empty text but a valid signature — natively replayable via the signed-thinking path. */
/** A block with empty text but a valid signature — stale after clear_thinking_20251015.
* Signing Anthropic endpoints must treat this as unreplayable (issue #4247). */
const SIGNED_EMPTY = { type: "thinking" as const, thinking: "", thinkingSignature: "sig_empty" };

function nativeThinkingCount(payload: { messages: unknown[] }): number {
Expand Down Expand Up @@ -169,7 +170,7 @@ describe("Anthropic unreplayable latest-assistant thinking", () => {
expect(JSON.stringify(payload.messages)).toContain("sig_early");
});

it("does not degrade when the latest turn has signed-but-empty thinking", async () => {
it("degrades when the latest turn has signed-but-empty thinking (clear_thinking)", async () => {
const payload = await capturePayload([
user,
assistantTurn([SIGNED_EARLY], "toolu_a"),
Expand All @@ -179,10 +180,46 @@ describe("Anthropic unreplayable latest-assistant thinking", () => {
toolResult("toolu_b"),
]);

// A block with empty text but a valid signature is natively replayable —
// convertAnthropicMessages forwards it via the signed-thinking path — so
// both signed blocks are preserved.
expect(nativeThinkingCount(payload)).toBe(2);
expect(JSON.stringify(payload.messages)).toContain("sig_empty");
// A signed block whose text was emptied by clear_thinking_20251015 carries
// a stale signature. Signing endpoints reject it, so the pre-emptive local
// degrade drops all native thinking from the replay (issue #4247).
expect(nativeThinkingCount(payload)).toBe(0);
expect(JSON.stringify(payload.messages)).not.toContain("sig_empty");
expect(JSON.stringify(payload.messages)).not.toContain("sig_early");
});

it("drops signed-empty historical thinking on signing endpoints (clear_thinking)", async () => {
const payload = await capturePayload([
user,
assistantTurn([SIGNED_EMPTY], "toolu_a"),
toolResult("toolu_a"),
{ ...user, content: "again", timestamp: Date.now() + 1 },
assistantTurn([SIGNED_LATE], "toolu_b"),
toolResult("toolu_b"),
]);

// The historical signed-empty block is dropped by transform-messages, so it
// never reaches the wire. The latest turn's valid signed thinking survives.
expect(JSON.stringify(payload.messages)).not.toContain("sig_empty");
expect(JSON.stringify(payload.messages)).toContain("sig_late");
expect(nativeThinkingCount(payload)).toBe(1);
});

it("does not degrade a non-signing endpoint whose latest turn has signed-empty thinking", async () => {
const payload = await capturePayload(
[
user,
assistantTurn([SIGNED_EARLY], "toolu_a", deepseekModel),
toolResult("toolu_a"),
{ ...user, content: "again", timestamp: Date.now() + 1 },
assistantTurn([SIGNED_EMPTY], "toolu_b", deepseekModel),
toolResult("toolu_b"),
],
deepseekModel,
);

// DeepSeek does not sign thinking and does not validate thinking presence.
// Signed-empty blocks are harmless on non-signing endpoints.
expect(JSON.stringify(payload.messages)).toContain("sig_early");
});
});
Loading
Loading