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.

File diff suppressed because it is too large Load Diff

View File

@@ -3,308 +3,341 @@ import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { import {
readFirstUserMessageFromTranscript, readFirstUserMessageFromTranscript,
readLastMessagePreviewFromTranscript, readLastMessagePreviewFromTranscript,
} from "./session-utils.fs.js"; } from "./session-utils.fs.js";
describe("readFirstUserMessageFromTranscript", () => { describe("readFirstUserMessageFromTranscript", () => {
let tmpDir: string; let tmpDir: string;
let storePath: string; let storePath: string;
beforeEach(() => { beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-")); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-"));
storePath = path.join(tmpDir, "sessions.json"); storePath = path.join(tmpDir, "sessions.json");
}); });
afterEach(() => { afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true }); fs.rmSync(tmpDir, { recursive: true, force: true });
}); });
test("returns null when transcript file does not exist", () => { test("returns null when transcript file does not exist", () => {
const result = readFirstUserMessageFromTranscript("nonexistent-session", storePath); const result = readFirstUserMessageFromTranscript("nonexistent-session", storePath);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
test("returns first user message from transcript with string content", () => { test("returns first user message from transcript with string content", () => {
const sessionId = "test-session-1"; const sessionId = "test-session-1";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [ const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }), JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "user", content: "Hello world" } }), JSON.stringify({ message: { role: "user", content: "Hello world" } }),
JSON.stringify({ message: { role: "assistant", content: "Hi there" } }), JSON.stringify({ message: { role: "assistant", content: "Hi there" } }),
]; ];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath); const result = readFirstUserMessageFromTranscript(sessionId, storePath);
expect(result).toBe("Hello world"); expect(result).toBe("Hello world");
}); });
test("returns first user message from transcript with array content", () => { test("returns first user message from transcript with array content", () => {
const sessionId = "test-session-2"; const sessionId = "test-session-2";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [ const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }), JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ JSON.stringify({
message: { message: {
role: "user", role: "user",
content: [{ type: "text", text: "Array message content" }], content: [{ type: "text", text: "Array message content" }],
}, },
}), }),
]; ];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath); const result = readFirstUserMessageFromTranscript(sessionId, storePath);
expect(result).toBe("Array message content"); expect(result).toBe("Array message content");
}); });
test("skips non-user messages to find first user message", () => { test("returns first user message from transcript with input_text content", () => {
const sessionId = "test-session-3"; const sessionId = "test-session-2b";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [ const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }), JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "system", content: "System prompt" } }), JSON.stringify({
JSON.stringify({ message: { role: "assistant", content: "Greeting" } }), message: {
JSON.stringify({ message: { role: "user", content: "First user question" } }), role: "user",
]; content: [{ type: "input_text", text: "Input text content" }],
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); },
}),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath); const result = readFirstUserMessageFromTranscript(sessionId, storePath);
expect(result).toBe("First user question"); 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`);
const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "system", content: "System prompt" } }),
JSON.stringify({ message: { role: "assistant", content: "Greeting" } }),
JSON.stringify({ message: { role: "user", content: "First user question" } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
test("returns null when no user messages exist", () => { const result = readFirstUserMessageFromTranscript(sessionId, storePath);
const sessionId = "test-session-4"; expect(result).toBe("First user question");
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); });
const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "system", content: "System prompt" } }),
JSON.stringify({ message: { role: "assistant", content: "Greeting" } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath); test("returns null when no user messages exist", () => {
expect(result).toBeNull(); const sessionId = "test-session-4";
}); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "system", content: "System prompt" } }),
JSON.stringify({ message: { role: "assistant", content: "Greeting" } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
test("handles malformed JSON lines gracefully", () => { const result = readFirstUserMessageFromTranscript(sessionId, storePath);
const sessionId = "test-session-5"; expect(result).toBeNull();
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); });
const lines = [
"not valid json",
JSON.stringify({ message: { role: "user", content: "Valid message" } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath); test("handles malformed JSON lines gracefully", () => {
expect(result).toBe("Valid message"); const sessionId = "test-session-5";
}); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
"not valid json",
JSON.stringify({ message: { role: "user", content: "Valid message" } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
test("uses sessionFile parameter when provided", () => { const result = readFirstUserMessageFromTranscript(sessionId, storePath);
const sessionId = "test-session-6"; expect(result).toBe("Valid message");
const customPath = path.join(tmpDir, "custom-transcript.jsonl"); });
const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "user", content: "Custom file message" } }),
];
fs.writeFileSync(customPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath, customPath); test("uses sessionFile parameter when provided", () => {
expect(result).toBe("Custom file message"); const sessionId = "test-session-6";
}); const customPath = path.join(tmpDir, "custom-transcript.jsonl");
const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "user", content: "Custom file message" } }),
];
fs.writeFileSync(customPath, lines.join("\n"), "utf-8");
test("trims whitespace from message content", () => { const result = readFirstUserMessageFromTranscript(sessionId, storePath, customPath);
const sessionId = "test-session-7"; expect(result).toBe("Custom file message");
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); });
const lines = [JSON.stringify({ message: { role: "user", content: " Padded message " } })];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath); test("trims whitespace from message content", () => {
expect(result).toBe("Padded message"); const sessionId = "test-session-7";
}); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [JSON.stringify({ message: { role: "user", content: " Padded message " } })];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
test("returns null for empty content", () => { const result = readFirstUserMessageFromTranscript(sessionId, storePath);
const sessionId = "test-session-8"; expect(result).toBe("Padded message");
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); });
const lines = [
JSON.stringify({ message: { role: "user", content: "" } }),
JSON.stringify({ message: { role: "user", content: "Second message" } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath); test("returns null for empty content", () => {
expect(result).toBe("Second message"); const sessionId = "test-session-8";
}); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({ message: { role: "user", content: "" } }),
JSON.stringify({ message: { role: "user", content: "Second message" } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
expect(result).toBe("Second message");
});
}); });
describe("readLastMessagePreviewFromTranscript", () => { describe("readLastMessagePreviewFromTranscript", () => {
let tmpDir: string; let tmpDir: string;
let storePath: string; let storePath: string;
beforeEach(() => { beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-")); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-"));
storePath = path.join(tmpDir, "sessions.json"); storePath = path.join(tmpDir, "sessions.json");
}); });
afterEach(() => { afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true }); fs.rmSync(tmpDir, { recursive: true, force: true });
}); });
test("returns null when transcript file does not exist", () => { test("returns null when transcript file does not exist", () => {
const result = readLastMessagePreviewFromTranscript("nonexistent-session", storePath); const result = readLastMessagePreviewFromTranscript("nonexistent-session", storePath);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
test("returns null for empty file", () => { test("returns null for empty file", () => {
const sessionId = "test-last-empty"; const sessionId = "test-last-empty";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
fs.writeFileSync(transcriptPath, "", "utf-8"); fs.writeFileSync(transcriptPath, "", "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath); const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
test("returns last user message from transcript", () => { test("returns last user message from transcript", () => {
const sessionId = "test-last-user"; const sessionId = "test-last-user";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [ const lines = [
JSON.stringify({ message: { role: "user", content: "First user" } }), JSON.stringify({ message: { role: "user", content: "First user" } }),
JSON.stringify({ message: { role: "assistant", content: "First assistant" } }), JSON.stringify({ message: { role: "assistant", content: "First assistant" } }),
JSON.stringify({ message: { role: "user", content: "Last user message" } }), JSON.stringify({ message: { role: "user", content: "Last user message" } }),
]; ];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath); const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Last user message"); expect(result).toBe("Last user message");
}); });
test("returns last assistant message from transcript", () => { test("returns last assistant message from transcript", () => {
const sessionId = "test-last-assistant"; const sessionId = "test-last-assistant";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [ const lines = [
JSON.stringify({ message: { role: "user", content: "User question" } }), JSON.stringify({ message: { role: "user", content: "User question" } }),
JSON.stringify({ message: { role: "assistant", content: "Final assistant reply" } }), JSON.stringify({ message: { role: "assistant", content: "Final assistant reply" } }),
]; ];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath); const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Final assistant reply"); expect(result).toBe("Final assistant reply");
}); });
test("skips system messages to find last user/assistant", () => { test("skips system messages to find last user/assistant", () => {
const sessionId = "test-last-skip-system"; const sessionId = "test-last-skip-system";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [ const lines = [
JSON.stringify({ message: { role: "user", content: "Real last" } }), JSON.stringify({ message: { role: "user", content: "Real last" } }),
JSON.stringify({ message: { role: "system", content: "System at end" } }), JSON.stringify({ message: { role: "system", content: "System at end" } }),
]; ];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath); const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Real last"); expect(result).toBe("Real last");
}); });
test("returns null when no user/assistant messages exist", () => { test("returns null when no user/assistant messages exist", () => {
const sessionId = "test-last-no-match"; const sessionId = "test-last-no-match";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [ const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }), JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "system", content: "Only system" } }), JSON.stringify({ message: { role: "system", content: "Only system" } }),
]; ];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath); const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBeNull(); expect(result).toBeNull();
}); });
test("handles malformed JSON lines gracefully", () => { test("handles malformed JSON lines gracefully", () => {
const sessionId = "test-last-malformed"; const sessionId = "test-last-malformed";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [ const lines = [
JSON.stringify({ message: { role: "user", content: "Valid first" } }), JSON.stringify({ message: { role: "user", content: "Valid first" } }),
"not valid json at end", "not valid json at end",
]; ];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath); const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Valid first"); expect(result).toBe("Valid first");
}); });
test("handles array content format", () => { test("handles array content format", () => {
const sessionId = "test-last-array"; const sessionId = "test-last-array";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [ const lines = [
JSON.stringify({ JSON.stringify({
message: { message: {
role: "assistant", role: "assistant",
content: [{ type: "text", text: "Array content response" }], content: [{ type: "text", text: "Array content response" }],
}, },
}), }),
]; ];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath); const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Array content response"); expect(result).toBe("Array content response");
}); });
test("uses sessionFile parameter when provided", () => { test("handles output_text content format", () => {
const sessionId = "test-last-custom"; const sessionId = "test-last-output-text";
const customPath = path.join(tmpDir, "custom-last.jsonl"); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [JSON.stringify({ message: { role: "user", content: "Custom file last" } })]; const lines = [
fs.writeFileSync(customPath, lines.join("\n"), "utf-8"); 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, customPath); const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Custom file last"); 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");
const lines = [JSON.stringify({ message: { role: "user", content: "Custom file last" } })];
fs.writeFileSync(customPath, lines.join("\n"), "utf-8");
test("trims whitespace from message content", () => { const result = readLastMessagePreviewFromTranscript(sessionId, storePath, customPath);
const sessionId = "test-last-trim"; expect(result).toBe("Custom file last");
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); });
const lines = [
JSON.stringify({ message: { role: "assistant", content: " Padded response " } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath); test("trims whitespace from message content", () => {
expect(result).toBe("Padded response"); const sessionId = "test-last-trim";
}); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({ message: { role: "assistant", content: " Padded response " } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
test("skips empty content to find previous message", () => { const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
const sessionId = "test-last-skip-empty"; expect(result).toBe("Padded response");
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); });
const lines = [
JSON.stringify({ message: { role: "assistant", content: "Has content" } }),
JSON.stringify({ message: { role: "user", content: "" } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath); test("skips empty content to find previous message", () => {
expect(result).toBe("Has content"); const sessionId = "test-last-skip-empty";
}); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({ message: { role: "assistant", content: "Has content" } }),
JSON.stringify({ message: { role: "user", content: "" } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
test("reads from end of large file (16KB window)", () => { const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
const sessionId = "test-last-large"; expect(result).toBe("Has content");
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); });
const padding = JSON.stringify({ message: { role: "user", content: "x".repeat(500) } });
const lines: string[] = [];
for (let i = 0; i < 50; i++) {
lines.push(padding);
}
lines.push(JSON.stringify({ message: { role: "assistant", content: "Last in large file" } }));
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath); test("reads from end of large file (16KB window)", () => {
expect(result).toBe("Last in large file"); const sessionId = "test-last-large";
}); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const padding = JSON.stringify({ message: { role: "user", content: "x".repeat(500) } });
const lines: string[] = [];
for (let i = 0; i < 50; i++) {
lines.push(padding);
}
lines.push(JSON.stringify({ message: { role: "assistant", content: "Last in large file" } }));
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
test("handles valid UTF-8 content", () => { const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
const sessionId = "test-last-utf8"; expect(result).toBe("Last in large file");
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); });
const validLine = JSON.stringify({
message: { role: "user", content: "Valid UTF-8: 你好世界 🌍" },
});
fs.writeFileSync(transcriptPath, validLine, "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath); test("handles valid UTF-8 content", () => {
expect(result).toBe("Valid UTF-8: 你好世界 🌍"); const sessionId = "test-last-utf8";
}); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const validLine = JSON.stringify({
message: { role: "user", content: "Valid UTF-8: 你好世界 🌍" },
});
fs.writeFileSync(transcriptPath, validLine, "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Valid UTF-8: 你好世界 🌍");
});
}); });

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

@@ -1,24 +1,24 @@
import { import {
Input, Input,
matchesKey, matchesKey,
type SelectItem, type SelectItem,
SelectList, SelectList,
type SelectListTheme, type SelectListTheme,
getEditorKeybindings, getEditorKeybindings,
} from "@mariozechner/pi-tui"; } from "@mariozechner/pi-tui";
import type { Component } from "@mariozechner/pi-tui"; import type { Component } from "@mariozechner/pi-tui";
import chalk from "chalk"; import chalk from "chalk";
import { fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js"; import { fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js";
export interface FilterableSelectItem extends SelectItem { export interface FilterableSelectItem extends SelectItem {
/** Additional searchable fields beyond label */ /** Additional searchable fields beyond label */
searchText?: string; searchText?: string;
/** Pre-computed lowercase search text (label + description + searchText) for filtering */ /** Pre-computed lowercase search text (label + description + searchText) for filtering */
searchTextLower?: string; searchTextLower?: string;
} }
export interface FilterableSelectListTheme extends SelectListTheme { export interface FilterableSelectListTheme extends SelectListTheme {
filterLabel: (text: string) => string; filterLabel: (text: string) => string;
} }
/** /**
@@ -26,108 +26,118 @@ export interface FilterableSelectListTheme extends SelectListTheme {
* User types to filter, arrows/j/k to navigate, Enter to select, Escape to clear/cancel. * User types to filter, arrows/j/k to navigate, Enter to select, Escape to clear/cancel.
*/ */
export class FilterableSelectList implements Component { export class FilterableSelectList implements Component {
private input: Input; private input: Input;
private selectList: SelectList; private selectList: SelectList;
private allItems: FilterableSelectItem[]; private allItems: FilterableSelectItem[];
private maxVisible: number; private maxVisible: number;
private theme: FilterableSelectListTheme; private theme: FilterableSelectListTheme;
private filterText = ""; private filterText = "";
onSelect?: (item: SelectItem) => void; onSelect?: (item: SelectItem) => void;
onCancel?: () => void; onCancel?: () => void;
constructor(items: FilterableSelectItem[], maxVisible: number, theme: FilterableSelectListTheme) { constructor(items: FilterableSelectItem[], maxVisible: number, theme: FilterableSelectListTheme) {
this.allItems = prepareSearchItems(items); this.allItems = prepareSearchItems(items);
this.maxVisible = maxVisible; this.maxVisible = maxVisible;
this.theme = theme; this.theme = theme;
this.input = new Input(); this.input = new Input();
this.selectList = new SelectList(this.allItems, maxVisible, theme); this.selectList = new SelectList(this.allItems, maxVisible, theme);
} }
private applyFilter(): void { private applyFilter(): void {
const queryLower = this.filterText.toLowerCase(); const queryLower = this.filterText.toLowerCase();
if (!queryLower.trim()) { if (!queryLower.trim()) {
this.selectList = new SelectList(this.allItems, this.maxVisible, this.theme); this.selectList = new SelectList(this.allItems, this.maxVisible, this.theme);
return; return;
} }
const filtered = fuzzyFilterLower(this.allItems, queryLower); const filtered = fuzzyFilterLower(this.allItems, queryLower);
this.selectList = new SelectList(filtered, this.maxVisible, this.theme); this.selectList = new SelectList(filtered, this.maxVisible, this.theme);
} }
invalidate(): void { invalidate(): void {
this.input.invalidate(); this.input.invalidate();
this.selectList.invalidate(); this.selectList.invalidate();
} }
render(width: number): string[] { render(width: number): string[] {
const lines: string[] = []; const lines: string[] = [];
// Filter input row // Filter input row
const filterLabel = this.theme.filterLabel("Filter: "); const filterLabel = this.theme.filterLabel("Filter: ");
const inputLines = this.input.render(width - 8); const inputLines = this.input.render(width - 8);
const inputText = inputLines[0] ?? ""; const inputText = inputLines[0] ?? "";
lines.push(filterLabel + inputText); lines.push(filterLabel + inputText);
// Separator // Separator
lines.push(chalk.dim("─".repeat(width))); lines.push(chalk.dim("─".repeat(width)));
// Select list // Select list
const listLines = this.selectList.render(width); const listLines = this.selectList.render(width);
lines.push(...listLines); lines.push(...listLines);
return lines; return lines;
} }
handleInput(keyData: string): void { handleInput(keyData: string): void {
// Navigation: arrows, vim j/k, or ctrl+p/ctrl+n const allowVimNav = !this.filterText.trim();
if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p") || keyData === "k") {
this.selectList.handleInput("\x1b[A");
return;
}
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n") || keyData === "j") { // Navigation: arrows, vim j/k, or ctrl+p/ctrl+n
this.selectList.handleInput("\x1b[B"); if (
return; matchesKey(keyData, "up") ||
} matchesKey(keyData, "ctrl+p") ||
(allowVimNav && keyData === "k")
) {
this.selectList.handleInput("\x1b[A");
return;
}
// Enter selects if (
if (matchesKey(keyData, "enter")) { matchesKey(keyData, "down") ||
const selected = this.selectList.getSelectedItem(); matchesKey(keyData, "ctrl+n") ||
if (selected) { (allowVimNav && keyData === "j")
this.onSelect?.(selected); ) {
} this.selectList.handleInput("\x1b[B");
return; return;
} }
// Escape: clear filter or cancel // Enter selects
const kb = getEditorKeybindings(); if (matchesKey(keyData, "enter")) {
if (kb.matches(keyData, "selectCancel")) { const selected = this.selectList.getSelectedItem();
if (this.filterText) { if (selected) {
this.filterText = ""; this.onSelect?.(selected);
this.input.setValue(""); }
this.applyFilter(); return;
} else { }
this.onCancel?.();
}
return;
}
// All other input goes to filter // Escape: clear filter or cancel
const prevValue = this.input.getValue(); const kb = getEditorKeybindings();
this.input.handleInput(keyData); if (kb.matches(keyData, "selectCancel")) {
const newValue = this.input.getValue(); if (this.filterText) {
this.filterText = "";
this.input.setValue("");
this.applyFilter();
} else {
this.onCancel?.();
}
return;
}
if (newValue !== prevValue) { // All other input goes to filter
this.filterText = newValue; const prevValue = this.input.getValue();
this.applyFilter(); this.input.handleInput(keyData);
} const newValue = this.input.getValue();
}
getSelectedItem(): SelectItem | null { if (newValue !== prevValue) {
return this.selectList.getSelectedItem(); this.filterText = newValue;
} this.applyFilter();
}
}
getFilterText(): string { getSelectedItem(): SelectItem | null {
return this.filterText; return this.selectList.getSelectedItem();
} }
getFilterText(): string {
return this.filterText;
}
} }

View File

@@ -1,290 +1,300 @@
import { import {
type Component, type Component,
fuzzyFilter, fuzzyFilter,
getEditorKeybindings, getEditorKeybindings,
Input, Input,
isKeyRelease, isKeyRelease,
matchesKey, matchesKey,
type SelectItem, type SelectItem,
type SelectListTheme, type SelectListTheme,
truncateToWidth, truncateToWidth,
} from "@mariozechner/pi-tui"; } from "@mariozechner/pi-tui";
import { visibleWidth } from "../../terminal/ansi.js"; import { visibleWidth } from "../../terminal/ansi.js";
import { findWordBoundaryIndex } from "./fuzzy-filter.js"; import { findWordBoundaryIndex } from "./fuzzy-filter.js";
export interface SearchableSelectListTheme extends SelectListTheme { export interface SearchableSelectListTheme extends SelectListTheme {
searchPrompt: (text: string) => string; searchPrompt: (text: string) => string;
searchInput: (text: string) => string; searchInput: (text: string) => string;
matchHighlight: (text: string) => string; matchHighlight: (text: string) => string;
} }
/** /**
* A select list with a search input at the top for fuzzy filtering. * A select list with a search input at the top for fuzzy filtering.
*/ */
export class SearchableSelectList implements Component { export class SearchableSelectList implements Component {
private items: SelectItem[]; private items: SelectItem[];
private filteredItems: SelectItem[]; private filteredItems: SelectItem[];
private selectedIndex = 0; private selectedIndex = 0;
private maxVisible: number; private maxVisible: number;
private theme: SearchableSelectListTheme; private theme: SearchableSelectListTheme;
private searchInput: Input; private searchInput: Input;
onSelect?: (item: SelectItem) => void; onSelect?: (item: SelectItem) => void;
onCancel?: () => void; onCancel?: () => void;
onSelectionChange?: (item: SelectItem) => void; onSelectionChange?: (item: SelectItem) => void;
constructor(items: SelectItem[], maxVisible: number, theme: SearchableSelectListTheme) { constructor(items: SelectItem[], maxVisible: number, theme: SearchableSelectListTheme) {
this.items = items; this.items = items;
this.filteredItems = items; this.filteredItems = items;
this.maxVisible = maxVisible; this.maxVisible = maxVisible;
this.theme = theme; this.theme = theme;
this.searchInput = new Input(); this.searchInput = new Input();
} }
private updateFilter() { private updateFilter() {
const query = this.searchInput.getValue().trim(); const query = this.searchInput.getValue().trim();
if (!query) { if (!query) {
this.filteredItems = this.items; this.filteredItems = this.items;
} else { } else {
this.filteredItems = this.smartFilter(query); this.filteredItems = this.smartFilter(query);
} }
// Reset selection when filter changes // Reset selection when filter changes
this.selectedIndex = 0; this.selectedIndex = 0;
this.notifySelectionChange(); this.notifySelectionChange();
} }
/** /**
* Smart filtering that prioritizes: * Smart filtering that prioritizes:
* 1. Exact substring match in label (highest priority) * 1. Exact substring match in label (highest priority)
* 2. Word-boundary prefix match in label * 2. Word-boundary prefix match in label
* 3. Exact substring match in description * 3. Exact substring match in description
* 4. Fuzzy match (lowest priority) * 4. Fuzzy match (lowest priority)
*/ */
private smartFilter(query: string): SelectItem[] { private smartFilter(query: string): SelectItem[] {
const q = query.toLowerCase(); const q = query.toLowerCase();
type ScoredItem = { item: SelectItem; score: number }; type ScoredItem = { item: SelectItem; score: number };
const exactLabel: ScoredItem[] = []; const exactLabel: ScoredItem[] = [];
const wordBoundary: ScoredItem[] = []; const wordBoundary: ScoredItem[] = [];
const descriptionMatches: ScoredItem[] = []; const descriptionMatches: ScoredItem[] = [];
const fuzzyCandidates: SelectItem[] = []; const fuzzyCandidates: SelectItem[] = [];
for (const item of this.items) { for (const item of this.items) {
const label = item.label.toLowerCase(); const label = item.label.toLowerCase();
const desc = (item.description ?? "").toLowerCase(); const desc = (item.description ?? "").toLowerCase();
// Tier 1: Exact substring in label (score 0-99) // Tier 1: Exact substring in label (score 0-99)
const labelIndex = label.indexOf(q); const labelIndex = label.indexOf(q);
if (labelIndex !== -1) { if (labelIndex !== -1) {
// Earlier match = better score // Earlier match = better score
exactLabel.push({ item, score: labelIndex }); exactLabel.push({ item, score: labelIndex });
continue; continue;
} }
// Tier 2: Word-boundary prefix in label (score 100-199) // Tier 2: Word-boundary prefix in label (score 100-199)
const wordBoundaryIndex = findWordBoundaryIndex(label, q); const wordBoundaryIndex = findWordBoundaryIndex(label, q);
if (wordBoundaryIndex !== null) { if (wordBoundaryIndex !== null) {
wordBoundary.push({ item, score: wordBoundaryIndex }); wordBoundary.push({ item, score: wordBoundaryIndex });
continue; continue;
} }
// Tier 3: Exact substring in description (score 200-299) // Tier 3: Exact substring in description (score 200-299)
const descIndex = desc.indexOf(q); const descIndex = desc.indexOf(q);
if (descIndex !== -1) { if (descIndex !== -1) {
descriptionMatches.push({ item, score: descIndex }); descriptionMatches.push({ item, score: descIndex });
continue; continue;
} }
// Tier 4: Fuzzy match (score 300+) // Tier 4: Fuzzy match (score 300+)
fuzzyCandidates.push(item); fuzzyCandidates.push(item);
} }
exactLabel.sort(this.compareByScore); exactLabel.sort(this.compareByScore);
wordBoundary.sort(this.compareByScore); wordBoundary.sort(this.compareByScore);
descriptionMatches.sort(this.compareByScore); descriptionMatches.sort(this.compareByScore);
const fuzzyMatches = fuzzyFilter( const fuzzyMatches = fuzzyFilter(
fuzzyCandidates, fuzzyCandidates,
query, query,
(i) => `${i.label} ${i.description ?? ""}`, (i) => `${i.label} ${i.description ?? ""}`,
); );
return [ return [
...exactLabel.map((s) => s.item), ...exactLabel.map((s) => s.item),
...wordBoundary.map((s) => s.item), ...wordBoundary.map((s) => s.item),
...descriptionMatches.map((s) => s.item), ...descriptionMatches.map((s) => s.item),
...fuzzyMatches, ...fuzzyMatches,
]; ];
} }
private escapeRegex(str: string): string { private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
private compareByScore = ( private compareByScore = (
a: { item: SelectItem; score: number }, a: { item: SelectItem; score: number },
b: { item: SelectItem; score: number }, b: { item: SelectItem; score: number },
) => { ) => {
if (a.score !== b.score) return a.score - b.score; if (a.score !== b.score) return a.score - b.score;
return this.getItemLabel(a.item).localeCompare(this.getItemLabel(b.item)); return this.getItemLabel(a.item).localeCompare(this.getItemLabel(b.item));
}; };
private getItemLabel(item: SelectItem): string { private getItemLabel(item: SelectItem): string {
return item.label || item.value; return item.label || item.value;
} }
private highlightMatch(text: string, query: string): string { private highlightMatch(text: string, query: string): string {
const tokens = query const tokens = query
.trim() .trim()
.split(/\s+/) .split(/\s+/)
.map((token) => token.toLowerCase()) .map((token) => token.toLowerCase())
.filter((token) => token.length > 0); .filter((token) => token.length > 0);
if (tokens.length === 0) return text; if (tokens.length === 0) return text;
const uniqueTokens = Array.from(new Set(tokens)).sort((a, b) => b.length - a.length); const uniqueTokens = Array.from(new Set(tokens)).sort((a, b) => b.length - a.length);
let result = text; let result = text;
for (const token of uniqueTokens) { for (const token of uniqueTokens) {
const regex = new RegExp(this.escapeRegex(token), "gi"); const regex = new RegExp(this.escapeRegex(token), "gi");
result = result.replace(regex, (match) => this.theme.matchHighlight(match)); result = result.replace(regex, (match) => this.theme.matchHighlight(match));
} }
return result; return result;
} }
setSelectedIndex(index: number) { setSelectedIndex(index: number) {
this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1)); this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));
} }
invalidate() { invalidate() {
this.searchInput.invalidate(); this.searchInput.invalidate();
} }
render(width: number): string[] { render(width: number): string[] {
const lines: string[] = []; const lines: string[] = [];
// Search input line // Search input line
const promptText = "search: "; const promptText = "search: ";
const prompt = this.theme.searchPrompt(promptText); const prompt = this.theme.searchPrompt(promptText);
const inputWidth = Math.max(1, width - visibleWidth(prompt)); const inputWidth = Math.max(1, width - visibleWidth(prompt));
const inputLines = this.searchInput.render(inputWidth); const inputLines = this.searchInput.render(inputWidth);
const inputText = inputLines[0] ?? ""; const inputText = inputLines[0] ?? "";
lines.push(`${prompt}${this.theme.searchInput(inputText)}`); lines.push(`${prompt}${this.theme.searchInput(inputText)}`);
lines.push(""); // Spacer lines.push(""); // Spacer
const query = this.searchInput.getValue().trim(); const query = this.searchInput.getValue().trim();
// If no items match filter, show message // If no items match filter, show message
if (this.filteredItems.length === 0) { if (this.filteredItems.length === 0) {
lines.push(this.theme.noMatch(" No matches")); lines.push(this.theme.noMatch(" No matches"));
return lines; return lines;
} }
// Calculate visible range with scrolling // Calculate visible range with scrolling
const startIndex = Math.max( const startIndex = Math.max(
0, 0,
Math.min( Math.min(
this.selectedIndex - Math.floor(this.maxVisible / 2), this.selectedIndex - Math.floor(this.maxVisible / 2),
this.filteredItems.length - this.maxVisible, this.filteredItems.length - this.maxVisible,
), ),
); );
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length); const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
// Render visible items // Render visible items
for (let i = startIndex; i < endIndex; i++) { for (let i = startIndex; i < endIndex; i++) {
const item = this.filteredItems[i]; const item = this.filteredItems[i];
if (!item) continue; if (!item) continue;
const isSelected = i === this.selectedIndex; const isSelected = i === this.selectedIndex;
lines.push(this.renderItemLine(item, isSelected, width, query)); lines.push(this.renderItemLine(item, isSelected, width, query));
} }
// Show scroll indicator if needed // Show scroll indicator if needed
if (this.filteredItems.length > this.maxVisible) { if (this.filteredItems.length > this.maxVisible) {
const scrollInfo = `${this.selectedIndex + 1}/${this.filteredItems.length}`; const scrollInfo = `${this.selectedIndex + 1}/${this.filteredItems.length}`;
lines.push(this.theme.scrollInfo(` ${scrollInfo}`)); lines.push(this.theme.scrollInfo(` ${scrollInfo}`));
} }
return lines; return lines;
} }
private renderItemLine( private renderItemLine(
item: SelectItem, item: SelectItem,
isSelected: boolean, isSelected: boolean,
width: number, width: number,
query: string, query: string,
): string { ): string {
const prefix = isSelected ? "→ " : " "; const prefix = isSelected ? "→ " : " ";
const prefixWidth = prefix.length; const prefixWidth = prefix.length;
const displayValue = this.getItemLabel(item); const displayValue = this.getItemLabel(item);
if (item.description && width > 40) { if (item.description && width > 40) {
const maxValueWidth = Math.min(30, width - prefixWidth - 4); const maxValueWidth = Math.min(30, width - prefixWidth - 4);
const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); const truncatedValue = truncateToWidth(displayValue, maxValueWidth, "");
const valueText = this.highlightMatch(truncatedValue, query); const valueText = this.highlightMatch(truncatedValue, query);
const spacing = " ".repeat(Math.max(1, 32 - visibleWidth(valueText))); const spacing = " ".repeat(Math.max(1, 32 - visibleWidth(valueText)));
const descriptionStart = prefixWidth + visibleWidth(valueText) + spacing.length; const descriptionStart = prefixWidth + visibleWidth(valueText) + spacing.length;
const remainingWidth = width - descriptionStart - 2; const remainingWidth = width - descriptionStart - 2;
if (remainingWidth > 10) { if (remainingWidth > 10) {
const truncatedDesc = truncateToWidth(item.description, remainingWidth, ""); const truncatedDesc = truncateToWidth(item.description, remainingWidth, "");
const descText = isSelected const descText = isSelected
? this.highlightMatch(truncatedDesc, query) ? this.highlightMatch(truncatedDesc, query)
: this.highlightMatch(this.theme.description(truncatedDesc), query); : this.highlightMatch(this.theme.description(truncatedDesc), query);
const line = `${prefix}${valueText}${spacing}${descText}`; const line = `${prefix}${valueText}${spacing}${descText}`;
return isSelected ? this.theme.selectedText(line) : line; return isSelected ? this.theme.selectedText(line) : line;
} }
} }
const maxWidth = width - prefixWidth - 2; const maxWidth = width - prefixWidth - 2;
const truncatedValue = truncateToWidth(displayValue, maxWidth, ""); const truncatedValue = truncateToWidth(displayValue, maxWidth, "");
const valueText = this.highlightMatch(truncatedValue, query); const valueText = this.highlightMatch(truncatedValue, query);
const line = `${prefix}${valueText}`; const line = `${prefix}${valueText}`;
return isSelected ? this.theme.selectedText(line) : line; return isSelected ? this.theme.selectedText(line) : line;
} }
handleInput(keyData: string): void { handleInput(keyData: string): void {
if (isKeyRelease(keyData)) return; if (isKeyRelease(keyData)) return;
// Navigation keys const allowVimNav = !this.searchInput.getValue().trim();
if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p") || keyData === "k") {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.notifySelectionChange();
return;
}
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n") || keyData === "j") { // Navigation keys
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1); if (
this.notifySelectionChange(); matchesKey(keyData, "up") ||
return; matchesKey(keyData, "ctrl+p") ||
} (allowVimNav && keyData === "k")
) {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.notifySelectionChange();
return;
}
if (matchesKey(keyData, "enter")) { if (
const item = this.filteredItems[this.selectedIndex]; matchesKey(keyData, "down") ||
if (item && this.onSelect) { matchesKey(keyData, "ctrl+n") ||
this.onSelect(item); (allowVimNav && keyData === "j")
} ) {
return; this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
} this.notifySelectionChange();
return;
}
const kb = getEditorKeybindings(); if (matchesKey(keyData, "enter")) {
if (kb.matches(keyData, "selectCancel")) { const item = this.filteredItems[this.selectedIndex];
if (this.onCancel) { if (item && this.onSelect) {
this.onCancel(); this.onSelect(item);
} }
return; return;
} }
// Pass other keys to search input const kb = getEditorKeybindings();
const prevValue = this.searchInput.getValue(); if (kb.matches(keyData, "selectCancel")) {
this.searchInput.handleInput(keyData); if (this.onCancel) {
const newValue = this.searchInput.getValue(); this.onCancel();
}
return;
}
if (prevValue !== newValue) { // Pass other keys to search input
this.updateFilter(); const prevValue = this.searchInput.getValue();
} this.searchInput.handleInput(keyData);
} const newValue = this.searchInput.getValue();
private notifySelectionChange() { if (prevValue !== newValue) {
const item = this.filteredItems[this.selectedIndex]; this.updateFilter();
if (item && this.onSelectionChange) { }
this.onSelectionChange(item); }
}
}
getSelectedItem(): SelectItem | null { private notifySelectionChange() {
return this.filteredItems[this.selectedIndex] ?? null; const item = this.filteredItems[this.selectedIndex];
} if (item && this.onSelectionChange) {
this.onSelectionChange(item);
}
}
getSelectedItem(): SelectItem | null {
return this.filteredItems[this.selectedIndex] ?? null;
}
} }

View File

@@ -1,462 +1,463 @@
import type { Component, TUI } from "@mariozechner/pi-tui"; import type { Component, TUI } from "@mariozechner/pi-tui";
import { import {
formatThinkingLevels, formatThinkingLevels,
normalizeUsageDisplay, normalizeUsageDisplay,
resolveResponseUsageMode, resolveResponseUsageMode,
} from "../auto-reply/thinking.js"; } from "../auto-reply/thinking.js";
import { normalizeAgentId } from "../routing/session-key.js"; import { normalizeAgentId } from "../routing/session-key.js";
import { formatRelativeTime } from "../utils/time-format.js"; import { formatRelativeTime } from "../utils/time-format.js";
import { helpText, parseCommand } from "./commands.js"; import { helpText, parseCommand } from "./commands.js";
import type { ChatLog } from "./components/chat-log.js"; import type { ChatLog } from "./components/chat-log.js";
import { import {
createFilterableSelectList, createFilterableSelectList,
createSearchableSelectList, createSearchableSelectList,
createSettingsList, createSettingsList,
} from "./components/selectors.js"; } from "./components/selectors.js";
import type { GatewayChatClient } from "./gateway-chat.js"; import type { GatewayChatClient } from "./gateway-chat.js";
import { formatStatusSummary } from "./tui-status-summary.js"; import { formatStatusSummary } from "./tui-status-summary.js";
import type { import type {
AgentSummary, AgentSummary,
GatewayStatusSummary, GatewayStatusSummary,
TuiOptions, TuiOptions,
TuiStateAccess, TuiStateAccess,
} from "./tui-types.js"; } from "./tui-types.js";
type CommandHandlerContext = { type CommandHandlerContext = {
client: GatewayChatClient; client: GatewayChatClient;
chatLog: ChatLog; chatLog: ChatLog;
tui: TUI; tui: TUI;
opts: TuiOptions; opts: TuiOptions;
state: TuiStateAccess; state: TuiStateAccess;
deliverDefault: boolean; deliverDefault: boolean;
openOverlay: (component: Component) => void; openOverlay: (component: Component) => void;
closeOverlay: () => void; closeOverlay: () => void;
refreshSessionInfo: () => Promise<void>; refreshSessionInfo: () => Promise<void>;
loadHistory: () => Promise<void>; loadHistory: () => Promise<void>;
setSession: (key: string) => Promise<void>; setSession: (key: string) => Promise<void>;
refreshAgents: () => Promise<void>; refreshAgents: () => Promise<void>;
abortActive: () => Promise<void>; abortActive: () => Promise<void>;
setActivityStatus: (text: string) => void; setActivityStatus: (text: string) => void;
formatSessionKey: (key: string) => string; formatSessionKey: (key: string) => string;
}; };
export function createCommandHandlers(context: CommandHandlerContext) { export function createCommandHandlers(context: CommandHandlerContext) {
const { const {
client, client,
chatLog, chatLog,
tui, tui,
opts, opts,
state, state,
deliverDefault, deliverDefault,
openOverlay, openOverlay,
closeOverlay, closeOverlay,
refreshSessionInfo, refreshSessionInfo,
loadHistory, loadHistory,
setSession, setSession,
refreshAgents, refreshAgents,
abortActive, abortActive,
setActivityStatus, setActivityStatus,
formatSessionKey, formatSessionKey,
} = context; } = context;
const setAgent = async (id: string) => { const setAgent = async (id: string) => {
state.currentAgentId = normalizeAgentId(id); state.currentAgentId = normalizeAgentId(id);
await setSession(""); await setSession("");
}; };
const openModelSelector = async () => { const openModelSelector = async () => {
try { try {
const models = await client.listModels(); const models = await client.listModels();
if (models.length === 0) { if (models.length === 0) {
chatLog.addSystem("no models available"); chatLog.addSystem("no models available");
tui.requestRender(); tui.requestRender();
return; return;
} }
const items = models.map((model) => ({ const items = models.map((model) => ({
value: `${model.provider}/${model.id}`, value: `${model.provider}/${model.id}`,
label: `${model.provider}/${model.id}`, label: `${model.provider}/${model.id}`,
description: model.name && model.name !== model.id ? model.name : "", description: model.name && model.name !== model.id ? model.name : "",
})); }));
const selector = createSearchableSelectList(items, 9); const selector = createSearchableSelectList(items, 9);
selector.onSelect = (item) => { selector.onSelect = (item) => {
void (async () => { void (async () => {
try { try {
await client.patchSession({ await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
model: item.value, model: item.value,
}); });
chatLog.addSystem(`model set to ${item.value}`); chatLog.addSystem(`model set to ${item.value}`);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`model set failed: ${String(err)}`); chatLog.addSystem(`model set failed: ${String(err)}`);
} }
closeOverlay(); closeOverlay();
tui.requestRender(); tui.requestRender();
})(); })();
}; };
selector.onCancel = () => { selector.onCancel = () => {
closeOverlay(); closeOverlay();
tui.requestRender(); tui.requestRender();
}; };
openOverlay(selector); openOverlay(selector);
tui.requestRender(); tui.requestRender();
} catch (err) { } catch (err) {
chatLog.addSystem(`model list failed: ${String(err)}`); chatLog.addSystem(`model list failed: ${String(err)}`);
tui.requestRender(); tui.requestRender();
} }
}; };
const openAgentSelector = async () => { const openAgentSelector = async () => {
await refreshAgents(); await refreshAgents();
if (state.agents.length === 0) { if (state.agents.length === 0) {
chatLog.addSystem("no agents found"); chatLog.addSystem("no agents found");
tui.requestRender(); tui.requestRender();
return; return;
} }
const items = state.agents.map((agent: AgentSummary) => ({ const items = state.agents.map((agent: AgentSummary) => ({
value: agent.id, value: agent.id,
label: agent.name ? `${agent.id} (${agent.name})` : agent.id, label: agent.name ? `${agent.id} (${agent.name})` : agent.id,
description: agent.id === state.agentDefaultId ? "default" : "", description: agent.id === state.agentDefaultId ? "default" : "",
})); }));
const selector = createSearchableSelectList(items, 9); const selector = createSearchableSelectList(items, 9);
selector.onSelect = (item) => { selector.onSelect = (item) => {
void (async () => { void (async () => {
closeOverlay(); closeOverlay();
await setAgent(item.value); await setAgent(item.value);
tui.requestRender(); tui.requestRender();
})(); })();
}; };
selector.onCancel = () => { selector.onCancel = () => {
closeOverlay(); closeOverlay();
tui.requestRender(); tui.requestRender();
}; };
openOverlay(selector); openOverlay(selector);
tui.requestRender(); tui.requestRender();
}; };
const openSessionSelector = async () => { const openSessionSelector = async () => {
try { try {
const result = await client.listSessions({ const result = await client.listSessions({
includeGlobal: false, includeGlobal: false,
includeUnknown: false, includeUnknown: false,
includeDerivedTitles: true, includeDerivedTitles: true,
includeLastMessage: true, includeLastMessage: true,
agentId: state.currentAgentId, agentId: state.currentAgentId,
}); });
const items = result.sessions.map((session) => { const items = result.sessions.map((session) => {
const title = session.derivedTitle ?? session.displayName; const title = session.derivedTitle ?? session.displayName;
const formattedKey = formatSessionKey(session.key); const formattedKey = formatSessionKey(session.key);
// Avoid redundant "title (key)" when title matches key // Avoid redundant "title (key)" when title matches key
const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey; const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey;
// 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 =
return { timePart && preview ? `${timePart} · ${preview}` : (preview ?? timePart);
value: session.key, return {
label, value: session.key,
description, label,
searchText: [ description,
session.displayName, searchText: [
session.label, session.displayName,
session.subject, session.label,
session.sessionId, session.subject,
session.key, session.sessionId,
session.lastMessagePreview, session.key,
] session.lastMessagePreview,
.filter(Boolean) ]
.join(" "), .filter(Boolean)
}; .join(" "),
}); };
const selector = createFilterableSelectList(items, 9); });
selector.onSelect = (item) => { const selector = createFilterableSelectList(items, 9);
void (async () => { selector.onSelect = (item) => {
closeOverlay(); void (async () => {
await setSession(item.value); closeOverlay();
tui.requestRender(); await setSession(item.value);
})(); tui.requestRender();
}; })();
selector.onCancel = () => { };
closeOverlay(); selector.onCancel = () => {
tui.requestRender(); closeOverlay();
}; tui.requestRender();
openOverlay(selector); };
tui.requestRender(); openOverlay(selector);
} catch (err) { tui.requestRender();
chatLog.addSystem(`sessions list failed: ${String(err)}`); } catch (err) {
tui.requestRender(); chatLog.addSystem(`sessions list failed: ${String(err)}`);
} tui.requestRender();
}; }
};
const openSettings = () => { const openSettings = () => {
const items = [ const items = [
{ {
id: "tools", id: "tools",
label: "Tool output", label: "Tool output",
currentValue: state.toolsExpanded ? "expanded" : "collapsed", currentValue: state.toolsExpanded ? "expanded" : "collapsed",
values: ["collapsed", "expanded"], values: ["collapsed", "expanded"],
}, },
{ {
id: "thinking", id: "thinking",
label: "Show thinking", label: "Show thinking",
currentValue: state.showThinking ? "on" : "off", currentValue: state.showThinking ? "on" : "off",
values: ["off", "on"], values: ["off", "on"],
}, },
]; ];
const settings = createSettingsList( const settings = createSettingsList(
items, items,
(id, value) => { (id, value) => {
if (id === "tools") { if (id === "tools") {
state.toolsExpanded = value === "expanded"; state.toolsExpanded = value === "expanded";
chatLog.setToolsExpanded(state.toolsExpanded); chatLog.setToolsExpanded(state.toolsExpanded);
} }
if (id === "thinking") { if (id === "thinking") {
state.showThinking = value === "on"; state.showThinking = value === "on";
void loadHistory(); void loadHistory();
} }
tui.requestRender(); tui.requestRender();
}, },
() => { () => {
closeOverlay(); closeOverlay();
tui.requestRender(); tui.requestRender();
}, },
); );
openOverlay(settings); openOverlay(settings);
tui.requestRender(); tui.requestRender();
}; };
const handleCommand = async (raw: string) => { const handleCommand = async (raw: string) => {
const { name, args } = parseCommand(raw); const { name, args } = parseCommand(raw);
if (!name) return; if (!name) return;
switch (name) { switch (name) {
case "help": case "help":
chatLog.addSystem( chatLog.addSystem(
helpText({ helpText({
provider: state.sessionInfo.modelProvider, provider: state.sessionInfo.modelProvider,
model: state.sessionInfo.model, model: state.sessionInfo.model,
}), }),
); );
break; break;
case "status": case "status":
try { try {
const status = await client.getStatus(); const status = await client.getStatus();
if (typeof status === "string") { if (typeof status === "string") {
chatLog.addSystem(status); chatLog.addSystem(status);
break; break;
} }
if (status && typeof status === "object") { if (status && typeof status === "object") {
const lines = formatStatusSummary(status as GatewayStatusSummary); const lines = formatStatusSummary(status as GatewayStatusSummary);
for (const line of lines) chatLog.addSystem(line); for (const line of lines) chatLog.addSystem(line);
break; break;
} }
chatLog.addSystem("status: unknown response"); chatLog.addSystem("status: unknown response");
} catch (err) { } catch (err) {
chatLog.addSystem(`status failed: ${String(err)}`); chatLog.addSystem(`status failed: ${String(err)}`);
} }
break; break;
case "agent": case "agent":
if (!args) { if (!args) {
await openAgentSelector(); await openAgentSelector();
} else { } else {
await setAgent(args); await setAgent(args);
} }
break; break;
case "agents": case "agents":
await openAgentSelector(); await openAgentSelector();
break; break;
case "session": case "session":
if (!args) { if (!args) {
await openSessionSelector(); await openSessionSelector();
} else { } else {
await setSession(args); await setSession(args);
} }
break; break;
case "sessions": case "sessions":
await openSessionSelector(); await openSessionSelector();
break; break;
case "model": case "model":
if (!args) { if (!args) {
await openModelSelector(); await openModelSelector();
} else { } else {
try { try {
await client.patchSession({ await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
model: args, model: args,
}); });
chatLog.addSystem(`model set to ${args}`); chatLog.addSystem(`model set to ${args}`);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`model set failed: ${String(err)}`); chatLog.addSystem(`model set failed: ${String(err)}`);
} }
} }
break; break;
case "models": case "models":
await openModelSelector(); await openModelSelector();
break; break;
case "think": case "think":
if (!args) { if (!args) {
const levels = formatThinkingLevels( const levels = formatThinkingLevels(
state.sessionInfo.modelProvider, state.sessionInfo.modelProvider,
state.sessionInfo.model, state.sessionInfo.model,
"|", "|",
); );
chatLog.addSystem(`usage: /think <${levels}>`); chatLog.addSystem(`usage: /think <${levels}>`);
break; break;
} }
try { try {
await client.patchSession({ await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
thinkingLevel: args, thinkingLevel: args,
}); });
chatLog.addSystem(`thinking set to ${args}`); chatLog.addSystem(`thinking set to ${args}`);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`think failed: ${String(err)}`); chatLog.addSystem(`think failed: ${String(err)}`);
} }
break; break;
case "verbose": case "verbose":
if (!args) { if (!args) {
chatLog.addSystem("usage: /verbose <on|off>"); chatLog.addSystem("usage: /verbose <on|off>");
break; break;
} }
try { try {
await client.patchSession({ await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
verboseLevel: args, verboseLevel: args,
}); });
chatLog.addSystem(`verbose set to ${args}`); chatLog.addSystem(`verbose set to ${args}`);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`verbose failed: ${String(err)}`); chatLog.addSystem(`verbose failed: ${String(err)}`);
} }
break; break;
case "reasoning": case "reasoning":
if (!args) { if (!args) {
chatLog.addSystem("usage: /reasoning <on|off>"); chatLog.addSystem("usage: /reasoning <on|off>");
break; break;
} }
try { try {
await client.patchSession({ await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
reasoningLevel: args, reasoningLevel: args,
}); });
chatLog.addSystem(`reasoning set to ${args}`); chatLog.addSystem(`reasoning set to ${args}`);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`reasoning failed: ${String(err)}`); chatLog.addSystem(`reasoning failed: ${String(err)}`);
} }
break; break;
case "usage": { case "usage": {
const normalized = args ? normalizeUsageDisplay(args) : undefined; const normalized = args ? normalizeUsageDisplay(args) : undefined;
if (args && !normalized) { if (args && !normalized) {
chatLog.addSystem("usage: /usage <off|tokens|full>"); chatLog.addSystem("usage: /usage <off|tokens|full>");
break; break;
} }
const currentRaw = state.sessionInfo.responseUsage; const currentRaw = state.sessionInfo.responseUsage;
const current = resolveResponseUsageMode(currentRaw); const current = resolveResponseUsageMode(currentRaw);
const next = const next =
normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off"); normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off");
try { try {
await client.patchSession({ await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
responseUsage: next === "off" ? null : next, responseUsage: next === "off" ? null : next,
}); });
chatLog.addSystem(`usage footer: ${next}`); chatLog.addSystem(`usage footer: ${next}`);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`usage failed: ${String(err)}`); chatLog.addSystem(`usage failed: ${String(err)}`);
} }
break; break;
} }
case "elevated": case "elevated":
if (!args) { if (!args) {
chatLog.addSystem("usage: /elevated <on|off>"); chatLog.addSystem("usage: /elevated <on|off>");
break; break;
} }
try { try {
await client.patchSession({ await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
elevatedLevel: args, elevatedLevel: args,
}); });
chatLog.addSystem(`elevated set to ${args}`); chatLog.addSystem(`elevated set to ${args}`);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`elevated failed: ${String(err)}`); chatLog.addSystem(`elevated failed: ${String(err)}`);
} }
break; break;
case "activation": case "activation":
if (!args) { if (!args) {
chatLog.addSystem("usage: /activation <mention|always>"); chatLog.addSystem("usage: /activation <mention|always>");
break; break;
} }
try { try {
await client.patchSession({ await client.patchSession({
key: state.currentSessionKey, key: state.currentSessionKey,
groupActivation: args === "always" ? "always" : "mention", groupActivation: args === "always" ? "always" : "mention",
}); });
chatLog.addSystem(`activation set to ${args}`); chatLog.addSystem(`activation set to ${args}`);
await refreshSessionInfo(); await refreshSessionInfo();
} catch (err) { } catch (err) {
chatLog.addSystem(`activation failed: ${String(err)}`); chatLog.addSystem(`activation failed: ${String(err)}`);
} }
break; break;
case "new": case "new":
case "reset": case "reset":
try { try {
await client.resetSession(state.currentSessionKey); await client.resetSession(state.currentSessionKey);
chatLog.addSystem(`session ${state.currentSessionKey} reset`); chatLog.addSystem(`session ${state.currentSessionKey} reset`);
await loadHistory(); await loadHistory();
} catch (err) { } catch (err) {
chatLog.addSystem(`reset failed: ${String(err)}`); chatLog.addSystem(`reset failed: ${String(err)}`);
} }
break; break;
case "abort": case "abort":
await abortActive(); await abortActive();
break; break;
case "settings": case "settings":
openSettings(); openSettings();
break; break;
case "exit": case "exit":
case "quit": case "quit":
client.stop(); client.stop();
tui.stop(); tui.stop();
process.exit(0); process.exit(0);
break; break;
default: default:
chatLog.addSystem(`unknown command: /${name}`); chatLog.addSystem(`unknown command: /${name}`);
break; break;
} }
tui.requestRender(); tui.requestRender();
}; };
const sendMessage = async (text: string) => { const sendMessage = async (text: string) => {
try { try {
chatLog.addUser(text); chatLog.addUser(text);
tui.requestRender(); tui.requestRender();
setActivityStatus("sending"); setActivityStatus("sending");
const { runId } = await client.sendChat({ const { runId } = await client.sendChat({
sessionKey: state.currentSessionKey, sessionKey: state.currentSessionKey,
message: text, message: text,
thinking: opts.thinking, thinking: opts.thinking,
deliver: deliverDefault, deliver: deliverDefault,
timeoutMs: opts.timeoutMs, timeoutMs: opts.timeoutMs,
}); });
state.activeChatRunId = runId; state.activeChatRunId = runId;
setActivityStatus("waiting"); setActivityStatus("waiting");
} catch (err) { } catch (err) {
chatLog.addSystem(`send failed: ${String(err)}`); chatLog.addSystem(`send failed: ${String(err)}`);
setActivityStatus("error"); setActivityStatus("error");
} }
tui.requestRender(); tui.requestRender();
}; };
return { return {
handleCommand, handleCommand,
sendMessage, sendMessage,
openModelSelector, openModelSelector,
openAgentSelector, openAgentSelector,
openSessionSelector, openSessionSelector,
openSettings, openSettings,
setAgent, setAgent,
}; };
} }