fix: polish session picker filtering (#1271) (thanks @Whoaa512)

This commit is contained in:
Peter Steinberger
2026-01-20 16:46:15 +00:00
parent 36719690a2
commit faa5838147
8 changed files with 1623 additions and 1563 deletions

View File

@@ -63,7 +63,7 @@ Docs: https://docs.clawd.bot
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting. - Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
- Agents: clarify node_modules read-only guidance in agent instructions. - Agents: clarify node_modules read-only guidance in agent instructions.
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07. - TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. - TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) — thanks @Whoaa512.
### Fixes ### Fixes
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba. - UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.

View File

@@ -155,6 +155,7 @@ export function buildAgentSystemPrompt(params: {
arch?: string; arch?: string;
node?: string; node?: string;
model?: string; model?: string;
defaultModel?: string;
channel?: string; channel?: string;
capabilities?: string[]; capabilities?: string[];
}; };
@@ -564,6 +565,7 @@ export function buildRuntimeLine(
arch?: string; arch?: string;
node?: string; node?: string;
model?: string; model?: string;
defaultModel?: string;
}, },
runtimeChannel?: string, runtimeChannel?: string,
runtimeCapabilities: string[] = [], runtimeCapabilities: string[] = [],
@@ -579,6 +581,7 @@ export function buildRuntimeLine(
: "", : "",
runtimeInfo?.node ? `node=${runtimeInfo.node}` : "", runtimeInfo?.node ? `node=${runtimeInfo.node}` : "",
runtimeInfo?.model ? `model=${runtimeInfo.model}` : "", runtimeInfo?.model ? `model=${runtimeInfo.model}` : "",
runtimeInfo?.defaultModel ? `default_model=${runtimeInfo.defaultModel}` : "",
runtimeChannel ? `channel=${runtimeChannel}` : "", runtimeChannel ? `channel=${runtimeChannel}` : "",
runtimeChannel runtimeChannel
? `capabilities=${runtimeCapabilities.length > 0 ? runtimeCapabilities.join(",") : "none"}` ? `capabilities=${runtimeCapabilities.length > 0 ? runtimeCapabilities.join(",") : "none"}`

View File

@@ -57,6 +57,23 @@ describe("readFirstUserMessageFromTranscript", () => {
expect(result).toBe("Array message content"); expect(result).toBe("Array message content");
}); });
test("returns first user message from transcript with input_text content", () => {
const sessionId = "test-session-2b";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({
message: {
role: "user",
content: [{ type: "input_text", text: "Input text content" }],
},
}),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
expect(result).toBe("Input text content");
});
test("skips non-user messages to find first user message", () => { test("skips non-user messages to find first user message", () => {
const sessionId = "test-session-3"; const sessionId = "test-session-3";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
@@ -246,6 +263,22 @@ describe("readLastMessagePreviewFromTranscript", () => {
expect(result).toBe("Array content response"); expect(result).toBe("Array content response");
}); });
test("handles output_text content format", () => {
const sessionId = "test-last-output-text";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({
message: {
role: "assistant",
content: [{ type: "output_text", text: "Output text response" }],
},
}),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Output text response");
});
test("uses sessionFile parameter when provided", () => { test("uses sessionFile parameter when provided", () => {
const sessionId = "test-last-custom"; const sessionId = "test-last-custom";
const customPath = path.join(tmpDir, "custom-last.jsonl"); const customPath = path.join(tmpDir, "custom-last.jsonl");

View File

@@ -91,8 +91,10 @@ function extractTextFromContent(content: TranscriptMessage["content"]): string |
if (typeof content === "string") return content.trim() || null; if (typeof content === "string") return content.trim() || null;
if (!Array.isArray(content)) return null; if (!Array.isArray(content)) return null;
for (const part of content) { for (const part of content) {
if (part.type === "text" && typeof part.text === "string" && part.text.trim()) { if (!part || typeof part.text !== "string") continue;
return part.text.trim(); if (part.type === "text" || part.type === "output_text" || part.type === "input_text") {
const trimmed = part.text.trim();
if (trimmed) return trimmed;
} }
} }
return null; return null;

View File

@@ -84,7 +84,8 @@ export function deriveSessionTitle(
} }
if (firstUserMessage?.trim()) { if (firstUserMessage?.trim()) {
return truncateTitle(firstUserMessage.trim(), DERIVED_TITLE_MAX_LEN); const normalized = firstUserMessage.replace(/\s+/g, " ").trim();
return truncateTitle(normalized, DERIVED_TITLE_MAX_LEN);
} }
if (entry.sessionId) { if (entry.sessionId) {

View File

@@ -79,13 +79,23 @@ export class FilterableSelectList implements Component {
} }
handleInput(keyData: string): void { handleInput(keyData: string): void {
const allowVimNav = !this.filterText.trim();
// Navigation: arrows, vim j/k, or ctrl+p/ctrl+n // Navigation: arrows, vim j/k, or ctrl+p/ctrl+n
if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p") || keyData === "k") { if (
matchesKey(keyData, "up") ||
matchesKey(keyData, "ctrl+p") ||
(allowVimNav && keyData === "k")
) {
this.selectList.handleInput("\x1b[A"); this.selectList.handleInput("\x1b[A");
return; return;
} }
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n") || keyData === "j") { if (
matchesKey(keyData, "down") ||
matchesKey(keyData, "ctrl+n") ||
(allowVimNav && keyData === "j")
) {
this.selectList.handleInput("\x1b[B"); this.selectList.handleInput("\x1b[B");
return; return;
} }

View File

@@ -238,14 +238,24 @@ export class SearchableSelectList implements Component {
handleInput(keyData: string): void { handleInput(keyData: string): void {
if (isKeyRelease(keyData)) return; if (isKeyRelease(keyData)) return;
const allowVimNav = !this.searchInput.getValue().trim();
// Navigation keys // Navigation keys
if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p") || keyData === "k") { if (
matchesKey(keyData, "up") ||
matchesKey(keyData, "ctrl+p") ||
(allowVimNav && keyData === "k")
) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1); this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.notifySelectionChange(); this.notifySelectionChange();
return; return;
} }
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n") || keyData === "j") { if (
matchesKey(keyData, "down") ||
matchesKey(keyData, "ctrl+n") ||
(allowVimNav && keyData === "j")
) {
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1); this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
this.notifySelectionChange(); this.notifySelectionChange();
return; return;

View File

@@ -151,7 +151,8 @@ export function createCommandHandlers(context: CommandHandlerContext) {
// Build description: time + message preview // Build description: time + message preview
const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : ""; const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : "";
const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim(); const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim();
const description = preview ? `${timePart} · ${preview}` : timePart; const description =
timePart && preview ? `${timePart} · ${preview}` : (preview ?? timePart);
return { return {
value: session.key, value: session.key,
label, label,