fix(tui): preserve unicode fuzzy behavior

This commit is contained in:
can1357
2026-07-01 20:18:44 +02:00
parent 9a49549d62
commit 27f74dadce
3 changed files with 77 additions and 44 deletions
+30
View File
@@ -35,6 +35,36 @@
- Sped up fuzzy filtering in selectors (model, settings, file/tree, hook, OAuth) by preparing the query once per filter instead of once per candidate, and memoizing the per-text search index across keystrokes. Incremental typing over a 400-item list drops ~57% (6.7ms → 2.9ms for an 8-keystroke session) with no change to match ranking and no first-keystroke regression; long texts (pasted prompts, transcripts) bypass the cache so memory stays bounded.
## [16.2.13] - 2026-07-01
### Fixed
- Fixed fuzzy-search filtering for CJK and other non-ASCII queries by preserving Unicode letters and numbers during query normalization ([#4114](https://github.com/can1357/oh-my-pi/issues/4114)).
## [16.2.12] - 2026-07-01
### Fixed
- Optimized streaming markdown rendering to reuse already-rendered prefix lines and only render new content deltas, improving performance and reducing redraw flicker.
## [16.2.10] - 2026-06-30
### Fixed
- Fixed mid-prompt `/skill:<name>` autocomplete acceptance wiping the user's draft. The autocomplete now inserts the `/skill:<name> ` token at the cursor (replacing only the partial `/sk` slash token) and preserves prose typed before and after it, so a user can compose a prompt and reach for a skill without losing their train of thought ([#3913](https://github.com/can1357/oh-my-pi/issues/3913)).
## [16.2.9] - 2026-06-30
### Added
- Added `Editor.submit()` to allow programmatic composer submission, enabling integration with speech input and other automated flows.
## [16.2.7] - 2026-06-30
### Fixed
- Fixed an issue where a fast double-Escape keypress was swallowed and ignored, preventing double-escape gestures and subsequent Escape key handlers from firing.
## [16.2.3] - 2026-06-28
### Added
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "bun:test";
import { fuzzyMatch, fuzzyRank, resetFuzzyIndexCache } from "@oh-my-pi/pi-tui/fuzzy";
describe("fuzzy index cache", () => {
it("produces identical ordering whether the cache is cold or warm", () => {
const items = [
"openai/gpt-4o",
"openai/gpt-4o-mini",
"openai/gpt-4-turbo",
"openai/o3",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-4-opus",
"google/gemini-2.5-pro",
];
resetFuzzyIndexCache();
const cold = fuzzyRank(items, "gpt 4o", item => item).map(result => result.item);
// Second pass reuses the now-cached per-text indices; the result must be byte-for-byte identical.
const warm = fuzzyRank(items, "gpt 4o", item => item).map(result => result.item);
expect(warm).toEqual(cold);
});
it("matches long candidate texts (cache bypass) deterministically", () => {
const longText = `openai/gpt-4o ${"x".repeat(5000)}`;
resetFuzzyIndexCache();
const first = fuzzyMatch("gpt4", longText);
const second = fuzzyMatch("gpt4", longText);
expect(first).toEqual(second);
expect(first.matches).toBe(true);
});
});
describe("fuzzyRank empty-normalized query", () => {
it("still calls getText for every item when the query normalizes to empty", () => {
const seen: string[] = [];
const items = ["alpha", "beta", "gamma"];
const out = fuzzyRank(items, "!!!", item => {
seen.push(item);
return item;
});
// A non-blank query that normalizes to empty matches everything with score 0,
// and must still invoke getText per item (preserving callback side effects).
expect(seen).toEqual(items);
expect(out.map(result => result.item)).toEqual(items);
expect(out.every(result => result.score === 0)).toBe(true);
});
});
+1 -44
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { fuzzyFilter, fuzzyMatch, fuzzyRank, resetFuzzyIndexCache } from "@oh-my-pi/pi-tui/fuzzy";
import { fuzzyFilter, fuzzyMatch } from "@oh-my-pi/pi-tui/fuzzy";
describe("fuzzyFilter", () => {
it("does not satisfy long tokens by scattering letters across unrelated words", () => {
@@ -44,46 +44,3 @@ describe("fuzzyFilter", () => {
});
});
describe("fuzzy index cache", () => {
it("produces identical ordering whether the cache is cold or warm", () => {
const items = [
"openai/gpt-4o",
"openai/gpt-4o-mini",
"openai/gpt-4-turbo",
"openai/o3",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-4-opus",
"google/gemini-2.5-pro",
];
resetFuzzyIndexCache();
const cold = fuzzyRank(items, "gpt 4o", item => item).map(result => result.item);
// Second pass reuses the now-cached per-text indices; the result must be byte-for-byte identical.
const warm = fuzzyRank(items, "gpt 4o", item => item).map(result => result.item);
expect(warm).toEqual(cold);
});
it("matches long candidate texts (cache bypass) deterministically", () => {
const longText = `openai/gpt-4o ${"x".repeat(5000)}`;
resetFuzzyIndexCache();
const first = fuzzyMatch("gpt4", longText);
const second = fuzzyMatch("gpt4", longText);
expect(first).toEqual(second);
expect(first.matches).toBe(true);
});
});
describe("fuzzyRank empty-normalized query", () => {
it("still calls getText for every item when the query normalizes to empty", () => {
const seen: string[] = [];
const items = ["alpha", "beta", "gamma"];
const out = fuzzyRank(items, "!!!", item => {
seen.push(item);
return item;
});
// A non-blank query that normalizes to empty matches everything with score 0,
// and must still invoke getText per item (preserving callback side effects).
expect(seen).toEqual(items);
expect(out.map(result => result.item)).toEqual(items);
expect(out.every(result => result.score === 0)).toBe(true);
});
});