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] && (
-
+
{' '}*