diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fc56c1..67db9da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/session-finding-plan.md b/docs/session-finding-plan.md index 3b8b127..17ba650 100644 --- a/docs/session-finding-plan.md +++ b/docs/session-finding-plan.md @@ -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 diff --git a/package.json b/package.json index 61fc688..806b9d8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/session-search.test.ts b/src/session-search.test.ts index 533f9f4..2dde378 100644 --- a/src/session-search.test.ts +++ b/src/session-search.test.ts @@ -5,6 +5,8 @@ import { findPromptMatch, isMinorSession, matchesAllWords, + truncateMiddle, + windowAroundMatch, } from './session-search'; describe('matchesAllWords', () => { @@ -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); + 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); + }); + + 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); + }); +}); diff --git a/src/session-search.ts b/src/session-search.ts index 23df714..59f83d4 100644 --- a/src/session-search.ts +++ b/src/session-search.ts @@ -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; + 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; + 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)}…`; +}; diff --git a/src/switcher-ui.tsx b/src/switcher-ui.tsx index b272b6e..6be6867 100644 --- a/src/switcher-ui.tsx +++ b/src/switcher-ui.tsx @@ -10,12 +10,22 @@ import { ListViewSession, mergeSessionsById, } from './session-list-view'; +import { truncateMiddle, windowAroundMatch } from './session-search'; import TerminalTab from './terminal-tab'; type SwitcherMode = 'projects' | 'sessions' | 'terminal'; // import { fetchVSCodeBasedOpenedWindows, SERVER_URL, deleteRecentProjectRecord } from "./vscode-based-ide-utility" export const SERVER_URL = 'http://localhost:55688'; +// The matched-prompt line answers "why is this row here". It used to be #999 +// with a U+2315 marker unreadable at 11px (reported as "•" and then "ρ" by the +// person who asked for the feature), so it read as a second copy of the +// first-message line — issue #138. The identity now lives in the chip below +// rather than in the line's colour: an amber line body sat too close to the +// orange last-message line, and the snippet IS a user prompt, so the neutral +// prompt grey is also the honest colour for its text. +const SNIPPET_LINE_STYLE = { color: '#999', fontSize: '11px' } as const; + // Unified search-match highlight — high contrast on every row color scheme // (the previous per-site translucent styles were near-invisible on colored text). const SEARCH_HIGHLIGHT_STYLE = { @@ -26,6 +36,20 @@ const SEARCH_HIGHLIGHT_STYLE = { fontWeight: 600, } as const; +// Deliberately the SAME amber as SEARCH_HIGHLIGHT_STYLE: the chip and the +// highlighted words are one system, so the row reads as "search found this +// here" at a glance. The line's text stays the neutral prompt grey — an amber +// line body sat too close to the orange last-message line, and the snippet IS +// a user prompt, so colouring it as one is also the honest choice. +const SNIPPET_MARKER_STYLE = { + color: SEARCH_HIGHLIGHT_STYLE.color, + backgroundColor: SEARCH_HIGHLIGHT_STYLE.backgroundColor, + borderRadius: '2px', + padding: '0 4px', + fontSize: '10px', + fontWeight: 600, +} as const; + // Boundary header of the expanded minor-sessions group. Sticky: it pins to // the top while scrolled inside the minors zone, so collapsing never // requires scrolling back to the boundary row. @@ -631,6 +655,17 @@ function SwitcherApp() { ]); const isSearchingSessions = sessionSearchValue.trim().length > 0; + const searchWordsLower = sessionSearchValue + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + // One rule for every length-capped line in a row: while searching, the window + // moves to the first match so you can see WHY the row is in the results; + // otherwise it keeps both ends, because these titles put the newest step last. + const fitToRow = (text: string, max: number) => + isSearchingSessions + ? windowAroundMatch(text, searchWordsLower, max) + : truncateMiddle(text, max); const hiddenSet = new Set(sessionMarks.hidden); const hasPins = Object.keys(sessionMarks.pins).length > 0; // Which rows appear, in which group, in which order — one pure function so @@ -1918,11 +1953,21 @@ function SwitcherApp() { /> {customTitles[session.sessionId] && ( - + {' '}* @@ -1932,7 +1977,10 @@ function SwitcherApp() { {' '}[] @@ -2043,7 +2091,10 @@ function SwitcherApp() { @@ -2053,7 +2104,10 @@ function SwitcherApp() { @@ -2064,7 +2118,10 @@ function SwitcherApp() { @@ -2084,8 +2141,10 @@ function SwitcherApp() { if (dupFirst || dupLast) return null; return (
- - ⌕ #{m.promptIndex + 1}{' '} + + + match #{m.promptIndex + 1} + {' '}