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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 1.0.86

- Feat: session rows are readable again when titles are long
- **Long titles now shorten from the middle** (`head … tail`) instead of losing everything past 35 characters. Measured on a 125-title corpus: the median title is **44 characters** against that 35-char cut, **64% of titles run past it**, and **48 of them shared their first 35 characters** — eight different sessions all rendered as `fred-ff nextjs backend and mcp arch`. Titles written as `A -> B > C` chains keep their newest step, which is the part that identifies the session
- **Hover a title to read it in full** — the row shows a shortened form, the tooltip shows everything
- **Searching moves each line's window to the match.** Every capped line — title, first and last message, branch, last AI reply — now shows *why* the row matched instead of filtering it in and showing nothing (39% of first prompts and 42% of last prompts were longer than the space they render in)
- **The `match #N` line is no longer mistaken for a message line** ([#138](https://github.com/grimmerk/codev/issues/138)): it was the same grey as the first-message line with an unreadable `⌕` glyph; it is now amber, in the same family as the search highlight, with a labelled marker

## 1.0.85

- Feat: three ways to browse pinned sessions, from the same `📌 Pinned (N)` header
Expand Down
7 changes: 7 additions & 0 deletions docs/session-finding-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,13 @@ Fixing the highlight is necessary; fixing identifiability is worth more.
a boolean. A title-only match therefore produces no snippet at all — which matters for the
35% of titled sessions with no `/rename` prompt to fall back on.

**Status: shipped** (v1.0.86). `truncateMiddle` and `windowAroundMatch` are pure
helpers in `session-search.ts`; the row applies them through one `fitToRow()`
so every capped line follows the same rule. The title budget went 35 → 60 and is
expected to need tuning against the real window. Issue #138's snippet-line
colour ships with it. R3 needed no separate fix: once the first/last lines
window to the match, suppressing the duplicate `⌕` line is correct again.

**Fixes (one PR):**

- **T1 — middle-ellipsis title** (`head … tail`) instead of a head-only slice, plus the full
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "CodeV",
"productName": "CodeV",
"version": "1.0.85",
"version": "1.0.86",
"description": "Quick switcher for VS Code, Cursor, and Claude Code sessions",
"repository": {
"type": "git",
Expand Down
185 changes: 185 additions & 0 deletions src/session-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
findPromptMatch,
isMinorSession,
matchesAllWords,
truncateMiddle,
windowAroundMatch,
} from './session-search';

describe('matchesAllWords', () => {
Expand Down Expand Up @@ -96,3 +98,186 @@ describe('isMinorSession', () => {
expect(isMinorSession({ isActive: false }, false, false)).toBe(false);
});
});

describe('truncateMiddle', () => {
it('keeps both ends, which is where these titles carry meaning', () => {
// Real shape from the corpus: the newest step is at the end.
const t =
'fred-ff nextjs backend and mcp arch clean up -> nextjs backend arch alternative > canva debug';
const out = truncateMiddle(t, 60);
expect(out.length).toBe(60);
expect(out.startsWith('fred-ff nextjs')).toBe(true);
expect(out.endsWith('canva debug')).toBe(true);
expect(out).toContain('…');
});

it('distinguishes titles that share a long prefix', () => {
const a = 'fred-ff nextjs backend and mcp arch clean up -> alternative 0';
const b = 'fred-ff nextjs backend and mcp arch clean up -> canva debug';
// The old head-only cut rendered both identically at 35 chars.
expect(a.slice(0, 35)).toBe(b.slice(0, 35));
expect(truncateMiddle(a, 40)).not.toBe(truncateMiddle(b, 40));
});

it('leaves short text untouched', () => {
expect(truncateMiddle('short', 60)).toBe('short');
expect(truncateMiddle('exactly-ten', 11)).toBe('exactly-ten');
});
});

describe('windowAroundMatch', () => {
const long = `${'a'.repeat(200)}NEEDLE${'b'.repeat(200)}`;

it('moves the window so a far-away match is actually visible', () => {
const out = windowAroundMatch(long, ['needle'], 60);
Comment thread
grimmerk marked this conversation as resolved.
expect(out).toContain('NEEDLE');
expect(out.startsWith('…')).toBe(true);
expect(out.endsWith('…')).toBe(true);
});

// The band between the fallback's head slice and `max`. A head-window check
// (`at < max - 1`) calls this visible, but truncateMiddle elides the middle,
// so the match lands in the gap — the one thing this helper promises cannot
// happen. Asking the fallback whether it shows the match closes it.
it('reveals a match that the fallback would elide from the middle', () => {
const text = `${'a'.repeat(40)}NEEDLE${'c'.repeat(200)}`;
expect(truncateMiddle(text, 60)).not.toContain('NEEDLE');
expect(windowAroundMatch(text, ['needle'], 60)).toContain('NEEDLE');
});

// A "capped" line that overruns its cap is not capped. Both ellipses count.
it('never exceeds the budget, ellipses included', () => {
for (const max of [20, 40, 60, 81]) {
for (const words of [['needle'], ['absent'], []]) {
expect(windowAroundMatch(long, words, max).length).toBeLessThanOrEqual(
max,
);
}
const nearEnd = `${'a'.repeat(300)}NEEDLE`;
expect(
windowAroundMatch(nearEnd, ['needle'], max).length,
).toBeLessThanOrEqual(max);
}
});

it('falls back to the ordinary rendering when nothing matches', () => {
expect(windowAroundMatch(long, ['absent'], 60)).toBe(
truncateMiddle(long, 60),
);
expect(windowAroundMatch(long, [], 60)).toBe(truncateMiddle(long, 60));
});

it('does not move the window for a match the fallback already shows', () => {
const text = `NEEDLE${'x'.repeat(200)}`;
expect(windowAroundMatch(text, ['needle'], 60)).toBe(
truncateMiddle(text, 60),
);
});

// Discriminating: the two candidate windows are disjoint, so centring on the
// later word would exclude the earlier one and the assertion would fail.
it('uses the EARLIEST match when several words hit', () => {
const text = `${'a'.repeat(150)}FIRST${'b'.repeat(400)}SECOND${'c'.repeat(150)}`;
const out = windowAroundMatch(text, ['second', 'first'], 60);
expect(out).toContain('FIRST');
expect(out).not.toContain('SECOND');
});

it('leaves text shorter than the window alone', () => {
expect(windowAroundMatch('tiny NEEDLE', ['needle'], 60)).toBe(
'tiny NEEDLE',
);
});

// A repeated word: the earliest occurrence picks where to centre, but if the
// ordinary rendering already shows a LATER one the reader still gets a
// highlight — and gets it without the text jumping. The contract is "a match
// is visible", not "this particular occurrence is".
it('keeps the ordinary rendering when it already shows some occurrence', () => {
// The earliest occurrence must be ELIDED and a later one visible, or the
// test never reaches the branch it names.
const text = `${'a'.repeat(40)}NEEDLE${'b'.repeat(200)}NEEDLE-tail`;
const plain = truncateMiddle(text, 60);
expect(plain.slice(0, 30)).not.toContain('NEEDLE'); // earliest is elided
expect(plain).toContain('NEEDLE'); // a later one survives
expect(windowAroundMatch(text, ['needle'], 60)).toBe(plain);
Comment thread
grimmerk marked this conversation as resolved.
});

it('windows when NO occurrence survives the ordinary rendering', () => {
const text = `${'a'.repeat(60)}NEEDLE${'b'.repeat(60)}NEEDLE${'c'.repeat(200)}`;
expect(truncateMiddle(text, 60)).not.toContain('NEEDLE');
expect(windowAroundMatch(text, ['needle'], 60)).toContain('NEEDLE');
});
// The windowed branch exists to contain the match, so a long search word
// must not be swallowed by the trailing ellipsis it makes room for.
it('keeps a LONG matched word whole, not just its start', () => {
const word = 'w'.repeat(30);
const text = `${'a'.repeat(200)}${word}${'b'.repeat(200)}`;
const out = windowAroundMatch(text, [word], 60);
expect(out).toContain(word);
expect(out.length).toBeLessThanOrEqual(60);
});

it('shows a word longer than the window from its first character', () => {
const word = 'w'.repeat(90);
const text = `${'a'.repeat(200)}${word}${'b'.repeat(200)}`;
const out = windowAroundMatch(text, [word], 60);
expect(out.length).toBeLessThanOrEqual(60);
// Cut at the end is unavoidable; cut at the START would hide where the
// match begins, which is the part that tells you why the row is here.
expect(out.replace(/…/g, '').startsWith('w')).toBe(true);
});
// toLowerCase() can CHANGE LENGTH — 'İ' becomes two code units — so an index
// taken in a lowercased copy does not address the same character in the
// source. Slicing by it shears the first matched character off the window.
it('keeps source offsets when case folding changes length', () => {
const text = `${'İ'.repeat(40)}${'a'.repeat(120)}NEEDLE${'b'.repeat(120)}`;
const out = windowAroundMatch(text, ['needle'], 60);
expect(out).toContain('NEEDLE');
expect(out.length).toBeLessThanOrEqual(60);
});

// The row is listed by matchesAllWords, so the window must find the same
// match — a regex with /i folds differently and would show no highlight.
it('agrees with the filter on Unicode case folding', () => {
const text = `${'a'.repeat(200)}İ${'b'.repeat(200)}`;
expect(matchesAllWords(text.toLowerCase(), ['i'])).toBe(true);
const out = windowAroundMatch(text, ['i'], 60);
expect(out).toContain('İ');
});

// A query can begin INSIDE a folding expansion: the combining dot is the
// second half of what 'İ' lowercases to. A prefix count can only name whole
// source characters, so it reported the character AFTER 'İ' and the span came
// back EMPTY — and an empty hit makes `plain.includes(hit)` trivially true,
// so the fallback was always accepted and the only match could stay hidden.
it('resolves a match that starts inside a folding expansion', () => {
const text = `${'a'.repeat(200)}İ${'b'.repeat(200)}`;
const out = windowAroundMatch(text, ['\u0307'], 60);
expect(out).toContain('İ');
expect(out.length).toBeLessThanOrEqual(60);
});

it('treats a query word as literal text, not a pattern', () => {
const text = `${'a'.repeat(200)}a+b(c)${'d'.repeat(200)}`;
expect(windowAroundMatch(text, ['a+b(c)'], 60)).toContain('a+b(c)');
});

it('honours a budget too small for any window', () => {
const text = `${'a'.repeat(200)}NEEDLE${'b'.repeat(200)}`;
for (const max of [0, 1, 2]) {
expect(
windowAroundMatch(text, ['needle'], max).length,
).toBeLessThanOrEqual(max);
}
});
});

describe('truncateMiddle lower boundary', () => {
it('never returns more than max, even at 0 and 1', () => {
expect(truncateMiddle('ab', 0)).toBe('');
expect(truncateMiddle('ab', 1)).toBe('…');
expect(truncateMiddle('abcdef', 2).length).toBe(2);
expect(truncateMiddle('abcdef', 3).length).toBe(3);
});
});
141 changes: 141 additions & 0 deletions src/session-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,144 @@ export const isMinorSession = (
!hasPrLink &&
typeof session.messageCount === 'number' &&
session.messageCount <= 2;

/**
* Where a match found in `text.toLowerCase()` lives in `text` itself.
*
* Folding is not length-preserving — 'İ' lowercases to two code units — so an
* offset found in the lowercased copy does not address the same character in
* the source. Matching still has to be done with `toLowerCase`, because that is
* what `matchesAllWords` uses to decide the row belongs in the results at all;
* a regex with `/i` folds differently and would list a row that shows no
* highlight. So the offset is translated, not re-derived.
*
* Translating by counting prefix lengths is not enough: a query can begin
* INSIDE an expansion (the combining dot of 'İ'), and a prefix count can only
* name whole source characters, so it reports the character AFTER the one the
* match started in and the span comes back empty. Recording which source
* character each folded unit came from answers both ends exactly.
*/
const foldedOrigins = (text: string): number[] => {
const origins: number[] = [];
for (let i = 0; i < text.length; i++) {
const folded = text[i].toLowerCase();
for (let k = 0; k < folded.length; k++) origins.push(i);
}
return origins;
};

/**
* Shorten from the MIDDLE, keeping both ends: `head … tail`.
*
* Head-only truncation is wrong for this app's titles specifically. Measured on
* the reference machine (125 unique custom titles): median 44 chars, 64% longer
* than the old 35-char cut, and 38% written as `A -> B > C` chains whose newest
* step sits at the END — so the cut removed exactly the part that identifies
* the session. Worse, 48 of 125 titles shared their first 35 characters: eight
* different sessions all rendered as `fred-ff nextjs backend and mcp arch`.
*/
export const truncateMiddle = (text: string, max: number): string => {
if (max <= 0) return '';
if (text.length <= max) return text;
// At one character there is no room for head, tail AND a marker; the marker
// is the only honest thing to keep.
if (max === 1) return '…';
// Bias the head slightly longer — it carries the topic, the tail the latest step.
const head = Math.ceil((max - 1) / 2);
const tail = max - 1 - head;
return `${text.slice(0, head)}…${tail > 0 ? text.slice(text.length - tail) : ''}`;
};

/**
* A `max`-char window over `text` that is guaranteed to show A match, with
* ellipses marking whichever end was cut.
*
* A match, not *the first* match: the earliest occurrence is only used to
* decide where to centre the window. If the ordinary rendering already shows
* some later occurrence of the same word, that rendering is returned unchanged
* — the reader still sees a highlight, and they see it in the familiar head+
* tail shape instead of a window that jumped for no visible reason.
*
* A row can only justify its place in the results if you can see why it
* matched. Every line here is length-capped, so a hit past the cap filtered the
* row in and then showed nothing — measured: 39% of first prompts and 42% of
* last prompts exceed the cap they are rendered at. When nothing matches (or
* the match already sits inside the head window) this falls back to `fallback`,
* which is the ordinary non-search rendering.
*/
export const windowAroundMatch = (
text: string,
wordsLower: string[],
max: number,
fallback: (text: string, max: number) => string = truncateMiddle,
): string => {
if (text.length <= max) return text;

// Match exactly as `matchesAllWords` does, then translate the offset into
// the source (see sourceIndexOfLowerIndex). Two matching rules for one
// question is how a row ends up listed with nothing highlighted.
const lower = text.toLowerCase();
let atLower = -1;
let wordLen = 0;
for (const w of wordsLower) {
if (!w) continue;
const i = lower.indexOf(w);
if (i !== -1 && (atLower === -1 || i < atLower)) {
atLower = i;
wordLen = w.length;
}
}
let at = -1;
let hit = '';
if (atLower !== -1) {
if (lower.length === text.length) {
// Nothing expanded, so the two strings share coordinates.
at = atLower;
hit = text.slice(at, at + wordLen);
} else {
const origins = foldedOrigins(text);
at = origins[atLower];
// Inclusive end: the source character the match's LAST folded unit came
// from, so a match that starts or ends inside an expansion still yields
// a non-empty span.
hit = text.slice(at, origins[atLower + wordLen - 1] + 1);
}
}
if (at === -1) return fallback(text, max);

// No useful window exists in one or two characters, and building one would
// spend the whole budget on ellipses; the fallback already honours the cap.
if (max <= 2) return fallback(text, max);

// ASK the fallback whether the match is already on screen rather than
// modelling where it keeps characters. An earlier version assumed a head
// window (`at < max - 1`) while the fallback truncated from the MIDDLE, so a
// match at index 40 of a 60-char budget was declared visible and then landed
// in the elided middle — the one thing this helper promises cannot happen.
const plain = fallback(text, max);
if (plain.toLowerCase().includes(hit.toLowerCase())) return plain;

// Every ellipsis rendered counts against `max`, or a "capped" line silently
// overruns the space the row reserved for it.
// Position roughly a third in, then pull the window forward if the match's
// TAIL would fall outside it. Centring alone is not enough: a long search
// word can start inside the window and still run past its end, so the
// trailing ellipsis swallows it and this branch fails at the one thing it
// exists to do.
const wantEnd = at + hit.length;
Comment thread
grimmerk marked this conversation as resolved.
let start = Math.max(0, at - Math.floor(max / 3));
// Reserve the trailing ellipsis while positioning; give it back below if the
// window reaches the end of the text.
let room = max - (start > 0 ? 1 : 0) - 1;
Comment thread
grimmerk marked this conversation as resolved.
if (start + room < wantEnd) {
// A word longer than the window cannot fit whole — then show it from its
// first character rather than from the middle of it.
start = Math.min(at, Math.max(0, wantEnd - room));
room = max - (start > 0 ? 1 : 0) - 1;
}
const lead = start > 0 ? '…' : '';
if (start + room >= text.length) {
return `${lead}${text.slice(start, start + (max - lead.length))}`;
}
return `${lead}${text.slice(start, start + room)}…`;
};
Loading
Loading