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.
- Agents: clarify node_modules read-only guidance in agent instructions.
- 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
- 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;
node?: string;
model?: string;
defaultModel?: string;
channel?: string;
capabilities?: string[];
};
@@ -564,6 +565,7 @@ export function buildRuntimeLine(
arch?: string;
node?: string;
model?: string;
defaultModel?: string;
},
runtimeChannel?: string,
runtimeCapabilities: string[] = [],
@@ -579,6 +581,7 @@ export function buildRuntimeLine(
: "",
runtimeInfo?.node ? `node=${runtimeInfo.node}` : "",
runtimeInfo?.model ? `model=${runtimeInfo.model}` : "",
runtimeInfo?.defaultModel ? `default_model=${runtimeInfo.defaultModel}` : "",
runtimeChannel ? `channel=${runtimeChannel}` : "",
runtimeChannel
? `capabilities=${runtimeCapabilities.length > 0 ? runtimeCapabilities.join(",") : "none"}`

View File

@@ -57,6 +57,23 @@ describe("readFirstUserMessageFromTranscript", () => {
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", () => {
const sessionId = "test-session-3";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
@@ -246,6 +263,22 @@ describe("readLastMessagePreviewFromTranscript", () => {
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", () => {
const sessionId = "test-last-custom";
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 (!Array.isArray(content)) return null;
for (const part of content) {
if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
return part.text.trim();
if (!part || typeof part.text !== "string") continue;
if (part.type === "text" || part.type === "output_text" || part.type === "input_text") {
const trimmed = part.text.trim();
if (trimmed) return trimmed;
}
}
return null;

View File

@@ -84,7 +84,8 @@ export function deriveSessionTitle(
}
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) {

View File

@@ -79,13 +79,23 @@ export class FilterableSelectList implements Component {
}
handleInput(keyData: string): void {
const allowVimNav = !this.filterText.trim();
// 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");
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");
return;
}

View File

@@ -238,14 +238,24 @@ export class SearchableSelectList implements Component {
handleInput(keyData: string): void {
if (isKeyRelease(keyData)) return;
const allowVimNav = !this.searchInput.getValue().trim();
// 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.notifySelectionChange();
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.notifySelectionChange();
return;

View File

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