diff --git a/CHANGELOG.md b/CHANGELOG.md index fe4f8a42c..093ada895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +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. (#1271) — thanks @Whoaa512. ### Fixes - UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba. diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 1d441aa4a..cb63eb7ec 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -1,6 +1,5 @@ import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; -import { formatCliCommand } from "../cli/command-format.js"; import { listDeliverableMessageChannels } from "../utils/message-channel.js"; import type { ResolvedTimeFormat } from "./date-time.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; @@ -86,22 +85,8 @@ function buildMessagingSection(params: { messageChannelOptions: string; inlineButtonsEnabled: boolean; runtimeChannel?: string; - channelActions?: string[]; }) { if (params.isMinimal) return []; - - // Build channel-specific action description - let actionsDescription: string; - if (params.channelActions && params.channelActions.length > 0 && params.runtimeChannel) { - // Include "send" as a base action plus channel-specific actions - const allActions = new Set(["send", ...params.channelActions]); - const actionList = Array.from(allActions).sort().join(", "); - actionsDescription = `- Use \`message\` for proactive sends + channel actions. Current channel (${params.runtimeChannel}) supports: ${actionList}.`; - } else { - actionsDescription = - "- Use `message` for proactive sends + channel actions (send, react, edit, delete, etc.)."; - } - return [ "## Messaging", "- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)", @@ -111,7 +96,7 @@ function buildMessagingSection(params: { ? [ "", "### message tool", - actionsDescription, + "- Use `message` for proactive sends + channel actions (polls, reactions, etc.).", "- For `action=send`, include `to` and `message`.", `- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`, `- If you use \`message\` (\`action=send\`) to deliver your user-visible reply, respond with ONLY: ${SILENT_REPLY_TOKEN} (avoid duplicate replies).`, @@ -139,7 +124,7 @@ function buildDocsSection(params: { docsPath?: string; isMinimal: boolean; readT "Community: https://discord.com/invite/clawd", "Find new skills: https://clawdhub.com", "For Clawdbot behavior, commands, config, or architecture: consult local docs first.", - `When diagnosing issues, run \`${formatCliCommand("clawdbot status")}\` yourself when possible; only ask the user if you lack access (e.g., sandboxed).`, + "When diagnosing issues, run `clawdbot status` yourself when possible; only ask the user if you lack access (e.g., sandboxed).", "", ]; } @@ -170,10 +155,9 @@ export function buildAgentSystemPrompt(params: { arch?: string; node?: string; model?: string; + defaultModel?: string; channel?: string; capabilities?: string[]; - /** Supported message actions for the current channel (e.g., react, edit, unsend) */ - channelActions?: string[]; }; sandboxInfo?: { enabled: boolean; @@ -381,11 +365,11 @@ export function buildAgentSystemPrompt(params: { "## Clawdbot CLI Quick Reference", "Clawdbot is controlled via subcommands. Do not invent commands.", "To manage the Gateway daemon service (start/stop/restart):", - `- ${formatCliCommand("clawdbot daemon status")}`, - `- ${formatCliCommand("clawdbot daemon start")}`, - `- ${formatCliCommand("clawdbot daemon stop")}`, - `- ${formatCliCommand("clawdbot daemon restart")}`, - `If unsure, ask the user to run \`${formatCliCommand("clawdbot help")}\` (or \`${formatCliCommand("clawdbot daemon --help")}\`) and paste the output.`, + "- clawdbot daemon status", + "- clawdbot daemon start", + "- clawdbot daemon stop", + "- clawdbot daemon restart", + "If unsure, ask the user to run `clawdbot help` (or `clawdbot daemon --help`) and paste the output.", "", ...skillsSection, ...memorySection, @@ -484,7 +468,6 @@ export function buildAgentSystemPrompt(params: { messageChannelOptions, inlineButtonsEnabled, runtimeChannel, - channelActions: runtimeInfo?.channelActions, }), ]; diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 502af9230..217981bb2 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -3,81 +3,92 @@ import { Type } from "@sinclair/typebox"; import { NonEmptyString, SessionLabelString } from "./primitives.js"; export const SessionsListParamsSchema = Type.Object( - { - limit: Type.Optional(Type.Integer({ minimum: 1 })), - activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })), - includeGlobal: Type.Optional(Type.Boolean()), - includeUnknown: Type.Optional(Type.Boolean()), - label: Type.Optional(SessionLabelString), - spawnedBy: Type.Optional(NonEmptyString), - agentId: Type.Optional(NonEmptyString), - }, - { additionalProperties: false }, + { + limit: Type.Optional(Type.Integer({ minimum: 1 })), + activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })), + includeGlobal: Type.Optional(Type.Boolean()), + includeUnknown: Type.Optional(Type.Boolean()), + /** + * Read first 8KB of each session transcript to derive title from first user message. + * Performs a file read per session - use `limit` to bound result set on large stores. + */ + includeDerivedTitles: Type.Optional(Type.Boolean()), + /** + * Read last 16KB of each session transcript to extract most recent message preview. + * Performs a file read per session - use `limit` to bound result set on large stores. + */ + includeLastMessage: Type.Optional(Type.Boolean()), + label: Type.Optional(SessionLabelString), + spawnedBy: Type.Optional(NonEmptyString), + agentId: Type.Optional(NonEmptyString), + search: Type.Optional(Type.String()), + }, + { additionalProperties: false }, ); export const SessionsResolveParamsSchema = Type.Object( - { - key: Type.Optional(NonEmptyString), - label: Type.Optional(SessionLabelString), - agentId: Type.Optional(NonEmptyString), - spawnedBy: Type.Optional(NonEmptyString), - includeGlobal: Type.Optional(Type.Boolean()), - includeUnknown: Type.Optional(Type.Boolean()), - }, - { additionalProperties: false }, + { + key: Type.Optional(NonEmptyString), + label: Type.Optional(SessionLabelString), + agentId: Type.Optional(NonEmptyString), + spawnedBy: Type.Optional(NonEmptyString), + includeGlobal: Type.Optional(Type.Boolean()), + includeUnknown: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, ); export const SessionsPatchParamsSchema = Type.Object( - { - key: NonEmptyString, - label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])), - thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - responseUsage: Type.Optional( - Type.Union([ - Type.Literal("off"), - Type.Literal("tokens"), - Type.Literal("full"), - // Backward compat with older clients/stores. - Type.Literal("on"), - Type.Null(), - ]), - ), - elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), - sendPolicy: Type.Optional( - Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]), - ), - groupActivation: Type.Optional( - Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]), - ), - }, - { additionalProperties: false }, + { + key: NonEmptyString, + label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])), + thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + responseUsage: Type.Optional( + Type.Union([ + Type.Literal("off"), + Type.Literal("tokens"), + Type.Literal("full"), + // Backward compat with older clients/stores. + Type.Literal("on"), + Type.Null(), + ]), + ), + elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + sendPolicy: Type.Optional( + Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]), + ), + groupActivation: Type.Optional( + Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]), + ), + }, + { additionalProperties: false }, ); export const SessionsResetParamsSchema = Type.Object( - { key: NonEmptyString }, - { additionalProperties: false }, + { key: NonEmptyString }, + { additionalProperties: false }, ); export const SessionsDeleteParamsSchema = Type.Object( - { - key: NonEmptyString, - deleteTranscript: Type.Optional(Type.Boolean()), - }, - { additionalProperties: false }, + { + key: NonEmptyString, + deleteTranscript: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, ); export const SessionsCompactParamsSchema = Type.Object( - { - key: NonEmptyString, - maxLines: Type.Optional(Type.Integer({ minimum: 1 })), - }, - { additionalProperties: false }, + { + key: NonEmptyString, + maxLines: Type.Optional(Type.Integer({ minimum: 1 })), + }, + { additionalProperties: false }, ); diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts new file mode 100644 index 000000000..8e2ad67c6 --- /dev/null +++ b/src/gateway/session-utils.fs.test.ts @@ -0,0 +1,343 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + readFirstUserMessageFromTranscript, + readLastMessagePreviewFromTranscript, +} from "./session-utils.fs.js"; + +describe("readFirstUserMessageFromTranscript", () => { + let tmpDir: string; + let storePath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-")); + storePath = path.join(tmpDir, "sessions.json"); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("returns null when transcript file does not exist", () => { + const result = readFirstUserMessageFromTranscript("nonexistent-session", storePath); + expect(result).toBeNull(); + }); + + test("returns first user message from transcript with string content", () => { + const sessionId = "test-session-1"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ message: { role: "user", content: "Hello world" } }), + JSON.stringify({ message: { role: "assistant", content: "Hi there" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + expect(result).toBe("Hello world"); + }); + + test("returns first user message from transcript with array content", () => { + const sessionId = "test-session-2"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ + message: { + role: "user", + content: [{ type: "text", text: "Array message content" }], + }, + }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + 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`); + 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"); + + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + expect(result).toBe("First user question"); + }); + + test("returns null when no user messages exist", () => { + 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"); + + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + expect(result).toBeNull(); + }); + + test("handles malformed JSON lines gracefully", () => { + 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"); + + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + expect(result).toBe("Valid message"); + }); + + test("uses sessionFile parameter when provided", () => { + 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"); + + const result = readFirstUserMessageFromTranscript(sessionId, storePath, customPath); + expect(result).toBe("Custom file message"); + }); + + test("trims whitespace from message content", () => { + 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"); + + const result = readFirstUserMessageFromTranscript(sessionId, storePath); + expect(result).toBe("Padded message"); + }); + + test("returns null for empty content", () => { + 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", () => { + let tmpDir: string; + let storePath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-")); + storePath = path.join(tmpDir, "sessions.json"); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("returns null when transcript file does not exist", () => { + const result = readLastMessagePreviewFromTranscript("nonexistent-session", storePath); + expect(result).toBeNull(); + }); + + test("returns null for empty file", () => { + const sessionId = "test-last-empty"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + fs.writeFileSync(transcriptPath, "", "utf-8"); + + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBeNull(); + }); + + test("returns last user message from transcript", () => { + const sessionId = "test-last-user"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ message: { role: "user", content: "First user" } }), + JSON.stringify({ message: { role: "assistant", content: "First assistant" } }), + JSON.stringify({ message: { role: "user", content: "Last user message" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBe("Last user message"); + }); + + test("returns last assistant message from transcript", () => { + const sessionId = "test-last-assistant"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ message: { role: "user", content: "User question" } }), + JSON.stringify({ message: { role: "assistant", content: "Final assistant reply" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBe("Final assistant reply"); + }); + + test("skips system messages to find last user/assistant", () => { + const sessionId = "test-last-skip-system"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ message: { role: "user", content: "Real last" } }), + JSON.stringify({ message: { role: "system", content: "System at end" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBe("Real last"); + }); + + test("returns null when no user/assistant messages exist", () => { + const sessionId = "test-last-no-match"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ type: "session", version: 1, id: sessionId }), + JSON.stringify({ message: { role: "system", content: "Only system" } }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBeNull(); + }); + + test("handles malformed JSON lines gracefully", () => { + const sessionId = "test-last-malformed"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ message: { role: "user", content: "Valid first" } }), + "not valid json at end", + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBe("Valid first"); + }); + + test("handles array content format", () => { + const sessionId = "test-last-array"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const lines = [ + JSON.stringify({ + message: { + role: "assistant", + content: [{ type: "text", text: "Array content response" }], + }, + }), + ]; + fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); + + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + 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"); + const lines = [JSON.stringify({ message: { role: "user", content: "Custom file last" } })]; + fs.writeFileSync(customPath, lines.join("\n"), "utf-8"); + + const result = readLastMessagePreviewFromTranscript(sessionId, storePath, customPath); + expect(result).toBe("Custom file last"); + }); + + test("trims whitespace from message content", () => { + 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"); + + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBe("Padded response"); + }); + + test("skips empty content to find previous message", () => { + 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"); + + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBe("Has content"); + }); + + test("reads from end of large file (16KB window)", () => { + 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"); + + const result = readLastMessagePreviewFromTranscript(sessionId, storePath); + expect(result).toBe("Last in large file"); + }); + + test("handles valid UTF-8 content", () => { + 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: 你好世界 🌍"); + }); +}); diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index cee03bde1..eb8912359 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -79,3 +79,113 @@ export function capArrayByJsonBytes( const next = start > 0 ? items.slice(start) : items; return { items: next, bytes }; } + +const MAX_LINES_TO_SCAN = 10; + +type TranscriptMessage = { + role?: string; + content?: string | Array<{ type: string; text?: string }>; +}; + +function extractTextFromContent(content: TranscriptMessage["content"]): string | null { + if (typeof content === "string") return content.trim() || null; + if (!Array.isArray(content)) return null; + for (const part of content) { + 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; +} + +export function readFirstUserMessageFromTranscript( + sessionId: string, + storePath: string | undefined, + sessionFile?: string, + agentId?: string, +): string | null { + const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId); + const filePath = candidates.find((p) => fs.existsSync(p)); + if (!filePath) return null; + + let fd: number | null = null; + try { + fd = fs.openSync(filePath, "r"); + const buf = Buffer.alloc(8192); + const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0); + if (bytesRead === 0) return null; + const chunk = buf.toString("utf-8", 0, bytesRead); + const lines = chunk.split(/\r?\n/).slice(0, MAX_LINES_TO_SCAN); + + for (const line of lines) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line); + const msg = parsed?.message as TranscriptMessage | undefined; + if (msg?.role === "user") { + const text = extractTextFromContent(msg.content); + if (text) return text; + } + } catch { + // skip malformed lines + } + } + } catch { + // file read error + } finally { + if (fd !== null) fs.closeSync(fd); + } + return null; +} + +const LAST_MSG_MAX_BYTES = 16384; +const LAST_MSG_MAX_LINES = 20; + +export function readLastMessagePreviewFromTranscript( + sessionId: string, + storePath: string | undefined, + sessionFile?: string, + agentId?: string, +): string | null { + const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId); + const filePath = candidates.find((p) => fs.existsSync(p)); + if (!filePath) return null; + + let fd: number | null = null; + try { + fd = fs.openSync(filePath, "r"); + const stat = fs.fstatSync(fd); + const size = stat.size; + if (size === 0) return null; + + const readStart = Math.max(0, size - LAST_MSG_MAX_BYTES); + const readLen = Math.min(size, LAST_MSG_MAX_BYTES); + const buf = Buffer.alloc(readLen); + fs.readSync(fd, buf, 0, readLen, readStart); + + const chunk = buf.toString("utf-8"); + const lines = chunk.split(/\r?\n/).filter((l) => l.trim()); + const tailLines = lines.slice(-LAST_MSG_MAX_LINES); + + for (let i = tailLines.length - 1; i >= 0; i--) { + const line = tailLines[i]; + try { + const parsed = JSON.parse(line); + const msg = parsed?.message as TranscriptMessage | undefined; + if (msg?.role === "user" || msg?.role === "assistant") { + const text = extractTextFromContent(msg.content); + if (text) return text; + } + } catch { + // skip malformed + } + } + } catch { + // file error + } finally { + if (fd !== null) fs.closeSync(fd); + } + return null; +} diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index db24c17f4..a288bc4f7 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -6,6 +6,8 @@ import type { SessionEntry } from "../config/sessions.js"; import { capArrayByJsonBytes, classifySessionKey, + deriveSessionTitle, + listSessionsFromStore, parseGroupKey, resolveGatewaySessionStoreTarget, resolveSessionStoreKey, @@ -91,3 +93,242 @@ describe("gateway session utils", () => { expect(target.storePath).toBe(path.resolve(storeTemplate.replace("{agentId}", "ops"))); }); }); + +describe("deriveSessionTitle", () => { + test("returns undefined for undefined entry", () => { + expect(deriveSessionTitle(undefined)).toBeUndefined(); + }); + + test("prefers displayName when set", () => { + const entry = { + sessionId: "abc123", + updatedAt: Date.now(), + displayName: "My Custom Session", + subject: "Group Chat", + } as SessionEntry; + expect(deriveSessionTitle(entry)).toBe("My Custom Session"); + }); + + test("falls back to subject when displayName is missing", () => { + const entry = { + sessionId: "abc123", + updatedAt: Date.now(), + subject: "Dev Team Chat", + } as SessionEntry; + expect(deriveSessionTitle(entry)).toBe("Dev Team Chat"); + }); + + test("uses first user message when displayName and subject missing", () => { + const entry = { + sessionId: "abc123", + updatedAt: Date.now(), + } as SessionEntry; + expect(deriveSessionTitle(entry, "Hello, how are you?")).toBe("Hello, how are you?"); + }); + + test("truncates long first user message to 60 chars with ellipsis", () => { + const entry = { + sessionId: "abc123", + updatedAt: Date.now(), + } as SessionEntry; + const longMsg = + "This is a very long message that exceeds sixty characters and should be truncated appropriately"; + const result = deriveSessionTitle(entry, longMsg); + expect(result).toBeDefined(); + expect(result!.length).toBeLessThanOrEqual(60); + expect(result!.endsWith("…")).toBe(true); + }); + + test("truncates at word boundary when possible", () => { + const entry = { + sessionId: "abc123", + updatedAt: Date.now(), + } as SessionEntry; + const longMsg = "This message has many words and should be truncated at a word boundary nicely"; + const result = deriveSessionTitle(entry, longMsg); + expect(result).toBeDefined(); + expect(result!.endsWith("…")).toBe(true); + expect(result!.includes(" ")).toBe(false); + }); + + test("falls back to sessionId prefix with date", () => { + const entry = { + sessionId: "abcd1234-5678-90ef-ghij-klmnopqrstuv", + updatedAt: new Date("2024-03-15T10:30:00Z").getTime(), + } as SessionEntry; + const result = deriveSessionTitle(entry); + expect(result).toBe("abcd1234 (2024-03-15)"); + }); + + test("falls back to sessionId prefix without date when updatedAt missing", () => { + const entry = { + sessionId: "abcd1234-5678-90ef-ghij-klmnopqrstuv", + updatedAt: 0, + } as SessionEntry; + const result = deriveSessionTitle(entry); + expect(result).toBe("abcd1234"); + }); + + test("trims whitespace from displayName", () => { + const entry = { + sessionId: "abc123", + updatedAt: Date.now(), + displayName: " Padded Name ", + } as SessionEntry; + expect(deriveSessionTitle(entry)).toBe("Padded Name"); + }); + + test("ignores empty displayName and falls through", () => { + const entry = { + sessionId: "abc123", + updatedAt: Date.now(), + displayName: " ", + subject: "Actual Subject", + } as SessionEntry; + expect(deriveSessionTitle(entry)).toBe("Actual Subject"); + }); +}); + +describe("listSessionsFromStore search", () => { + const baseCfg = { + session: { mainKey: "main" }, + agents: { list: [{ id: "main", default: true }] }, + } as ClawdbotConfig; + + const makeStore = (): Record => ({ + "agent:main:work-project": { + sessionId: "sess-work-1", + updatedAt: Date.now(), + displayName: "Work Project Alpha", + label: "work", + } as SessionEntry, + "agent:main:personal-chat": { + sessionId: "sess-personal-1", + updatedAt: Date.now() - 1000, + displayName: "Personal Chat", + subject: "Family Reunion Planning", + } as SessionEntry, + "agent:main:discord:group:dev-team": { + sessionId: "sess-discord-1", + updatedAt: Date.now() - 2000, + label: "discord", + subject: "Dev Team Discussion", + } as SessionEntry, + }); + + test("returns all sessions when search is empty", () => { + const store = makeStore(); + const result = listSessionsFromStore({ + cfg: baseCfg, + storePath: "/tmp/sessions.json", + store, + opts: { search: "" }, + }); + expect(result.sessions.length).toBe(3); + }); + + test("returns all sessions when search is undefined", () => { + const store = makeStore(); + const result = listSessionsFromStore({ + cfg: baseCfg, + storePath: "/tmp/sessions.json", + store, + opts: {}, + }); + expect(result.sessions.length).toBe(3); + }); + + test("filters by displayName case-insensitively", () => { + const store = makeStore(); + const result = listSessionsFromStore({ + cfg: baseCfg, + storePath: "/tmp/sessions.json", + store, + opts: { search: "WORK PROJECT" }, + }); + expect(result.sessions.length).toBe(1); + expect(result.sessions[0].displayName).toBe("Work Project Alpha"); + }); + + test("filters by subject", () => { + const store = makeStore(); + const result = listSessionsFromStore({ + cfg: baseCfg, + storePath: "/tmp/sessions.json", + store, + opts: { search: "reunion" }, + }); + expect(result.sessions.length).toBe(1); + expect(result.sessions[0].subject).toBe("Family Reunion Planning"); + }); + + test("filters by label", () => { + const store = makeStore(); + const result = listSessionsFromStore({ + cfg: baseCfg, + storePath: "/tmp/sessions.json", + store, + opts: { search: "discord" }, + }); + expect(result.sessions.length).toBe(1); + expect(result.sessions[0].label).toBe("discord"); + }); + + test("filters by sessionId", () => { + const store = makeStore(); + const result = listSessionsFromStore({ + cfg: baseCfg, + storePath: "/tmp/sessions.json", + store, + opts: { search: "sess-personal" }, + }); + expect(result.sessions.length).toBe(1); + expect(result.sessions[0].sessionId).toBe("sess-personal-1"); + }); + + test("filters by key", () => { + const store = makeStore(); + const result = listSessionsFromStore({ + cfg: baseCfg, + storePath: "/tmp/sessions.json", + store, + opts: { search: "dev-team" }, + }); + expect(result.sessions.length).toBe(1); + expect(result.sessions[0].key).toBe("agent:main:discord:group:dev-team"); + }); + + test("returns empty array when no matches", () => { + const store = makeStore(); + const result = listSessionsFromStore({ + cfg: baseCfg, + storePath: "/tmp/sessions.json", + store, + opts: { search: "nonexistent-term" }, + }); + expect(result.sessions.length).toBe(0); + }); + + test("matches partial strings", () => { + const store = makeStore(); + const result = listSessionsFromStore({ + cfg: baseCfg, + storePath: "/tmp/sessions.json", + store, + opts: { search: "alpha" }, + }); + expect(result.sessions.length).toBe(1); + expect(result.sessions[0].displayName).toBe("Work Project Alpha"); + }); + + test("trims whitespace from search query", () => { + const store = makeStore(); + const result = listSessionsFromStore({ + cfg: baseCfg, + storePath: "/tmp/sessions.json", + store, + opts: { search: " personal " }, + }); + expect(result.sessions.length).toBe(1); + }); +}); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index b17018ae3..b77e44817 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -22,6 +22,10 @@ import { parseAgentSessionKey, } from "../routing/session-key.js"; import { normalizeSessionDeliveryFields } from "../utils/delivery-context.js"; +import { + readFirstUserMessageFromTranscript, + readLastMessagePreviewFromTranscript, +} from "./session-utils.fs.js"; import type { GatewayAgentRow, GatewaySessionRow, @@ -32,6 +36,8 @@ import type { export { archiveFileOnDisk, capArrayByJsonBytes, + readFirstUserMessageFromTranscript, + readLastMessagePreviewFromTranscript, readSessionMessages, resolveSessionTranscriptCandidates, } from "./session-utils.fs.js"; @@ -43,6 +49,52 @@ export type { SessionsPatchResult, } from "./session-utils.types.js"; +const DERIVED_TITLE_MAX_LEN = 60; + +function formatSessionIdPrefix(sessionId: string, updatedAt?: number | null): string { + const prefix = sessionId.slice(0, 8); + if (updatedAt && updatedAt > 0) { + const d = new Date(updatedAt); + const date = d.toISOString().slice(0, 10); + return `${prefix} (${date})`; + } + return prefix; +} + +function truncateTitle(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + const cut = text.slice(0, maxLen - 1); + const lastSpace = cut.lastIndexOf(" "); + if (lastSpace > maxLen * 0.6) return cut.slice(0, lastSpace) + "…"; + return cut + "…"; +} + +export function deriveSessionTitle( + entry: SessionEntry | undefined, + firstUserMessage?: string | null, +): string | undefined { + if (!entry) return undefined; + + if (entry.displayName?.trim()) { + return entry.displayName.trim(); + } + + if (entry.subject?.trim()) { + return entry.subject.trim(); + } + + if (firstUserMessage?.trim()) { + const normalized = firstUserMessage.replace(/\s+/g, " ").trim(); + return truncateTitle(normalized, DERIVED_TITLE_MAX_LEN); + } + + if (entry.sessionId) { + return formatSessionIdPrefix(entry.sessionId, entry.updatedAt); + } + + return undefined; +} + export function loadSessionEntry(sessionKey: string) { const cfg = loadConfig(); const sessionCfg = cfg.session; @@ -341,9 +393,12 @@ export function listSessionsFromStore(params: { const includeGlobal = opts.includeGlobal === true; const includeUnknown = opts.includeUnknown === true; + const includeDerivedTitles = opts.includeDerivedTitles === true; + const includeLastMessage = opts.includeLastMessage === true; const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : ""; const label = typeof opts.label === "string" ? opts.label.trim() : ""; const agentId = typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : ""; + const search = typeof opts.search === "string" ? opts.search.trim().toLowerCase() : ""; const activeMinutes = typeof opts.activeMinutes === "number" && Number.isFinite(opts.activeMinutes) ? Math.max(1, Math.floor(opts.activeMinutes)) @@ -400,6 +455,7 @@ export function listSessionsFromStore(params: { const deliveryFields = normalizeSessionDeliveryFields(entry); return { key, + entry, kind: classifySessionKey(key, entry), label: entry?.label, displayName, @@ -429,10 +485,17 @@ export function listSessionsFromStore(params: { lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel, lastTo: deliveryFields.lastTo ?? entry?.lastTo, lastAccountId: deliveryFields.lastAccountId ?? entry?.lastAccountId, - } satisfies GatewaySessionRow; + }; }) .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + if (search) { + sessions = sessions.filter((s) => { + const fields = [s.displayName, s.label, s.subject, s.sessionId, s.key]; + return fields.some((f) => typeof f === "string" && f.toLowerCase().includes(search)); + }); + } + if (activeMinutes !== undefined) { const cutoff = now - activeMinutes * 60_000; sessions = sessions.filter((s) => (s.updatedAt ?? 0) >= cutoff); @@ -443,11 +506,36 @@ export function listSessionsFromStore(params: { sessions = sessions.slice(0, limit); } + const finalSessions: GatewaySessionRow[] = sessions.map((s) => { + const { entry, ...rest } = s; + let derivedTitle: string | undefined; + let lastMessagePreview: string | undefined; + if (entry?.sessionId) { + if (includeDerivedTitles) { + const firstUserMsg = readFirstUserMessageFromTranscript( + entry.sessionId, + storePath, + entry.sessionFile, + ); + derivedTitle = deriveSessionTitle(entry, firstUserMsg); + } + if (includeLastMessage) { + const lastMsg = readLastMessagePreviewFromTranscript( + entry.sessionId, + storePath, + entry.sessionFile, + ); + if (lastMsg) lastMessagePreview = lastMsg; + } + } + return { ...rest, derivedTitle, lastMessagePreview } satisfies GatewaySessionRow; + }); + return { ts: now, path: storePath, - count: sessions.length, + count: finalSessions.length, defaults: getSessionDefaults(cfg), - sessions, + sessions: finalSessions, }; } diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 4735a0ff4..77774d6d7 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -13,6 +13,8 @@ export type GatewaySessionRow = { kind: "direct" | "group" | "global" | "unknown"; label?: string; displayName?: string; + derivedTitle?: string; + lastMessagePreview?: string; channel?: string; subject?: string; groupChannel?: string; diff --git a/src/tui/components/filterable-select-list.ts b/src/tui/components/filterable-select-list.ts new file mode 100644 index 000000000..67361bcf1 --- /dev/null +++ b/src/tui/components/filterable-select-list.ts @@ -0,0 +1,143 @@ +import { + Input, + matchesKey, + type SelectItem, + SelectList, + type SelectListTheme, + getEditorKeybindings, +} from "@mariozechner/pi-tui"; +import type { Component } from "@mariozechner/pi-tui"; +import chalk from "chalk"; +import { fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js"; + +export interface FilterableSelectItem extends SelectItem { + /** Additional searchable fields beyond label */ + searchText?: string; + /** Pre-computed lowercase search text (label + description + searchText) for filtering */ + searchTextLower?: string; +} + +export interface FilterableSelectListTheme extends SelectListTheme { + filterLabel: (text: string) => string; +} + +/** + * Combines text input filtering with a select list. + * User types to filter, arrows/j/k to navigate, Enter to select, Escape to clear/cancel. + */ +export class FilterableSelectList implements Component { + private input: Input; + private selectList: SelectList; + private allItems: FilterableSelectItem[]; + private maxVisible: number; + private theme: FilterableSelectListTheme; + private filterText = ""; + + onSelect?: (item: SelectItem) => void; + onCancel?: () => void; + + constructor(items: FilterableSelectItem[], maxVisible: number, theme: FilterableSelectListTheme) { + this.allItems = prepareSearchItems(items); + this.maxVisible = maxVisible; + this.theme = theme; + this.input = new Input(); + this.selectList = new SelectList(this.allItems, maxVisible, theme); + } + + private applyFilter(): void { + const queryLower = this.filterText.toLowerCase(); + if (!queryLower.trim()) { + this.selectList = new SelectList(this.allItems, this.maxVisible, this.theme); + return; + } + const filtered = fuzzyFilterLower(this.allItems, queryLower); + this.selectList = new SelectList(filtered, this.maxVisible, this.theme); + } + + invalidate(): void { + this.input.invalidate(); + this.selectList.invalidate(); + } + + render(width: number): string[] { + const lines: string[] = []; + + // Filter input row + const filterLabel = this.theme.filterLabel("Filter: "); + const inputLines = this.input.render(width - 8); + const inputText = inputLines[0] ?? ""; + lines.push(filterLabel + inputText); + + // Separator + lines.push(chalk.dim("─".repeat(width))); + + // Select list + const listLines = this.selectList.render(width); + lines.push(...listLines); + + return lines; + } + + 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") || + (allowVimNav && keyData === "k") + ) { + this.selectList.handleInput("\x1b[A"); + return; + } + + if ( + matchesKey(keyData, "down") || + matchesKey(keyData, "ctrl+n") || + (allowVimNav && keyData === "j") + ) { + this.selectList.handleInput("\x1b[B"); + return; + } + + // Enter selects + if (matchesKey(keyData, "enter")) { + const selected = this.selectList.getSelectedItem(); + if (selected) { + this.onSelect?.(selected); + } + return; + } + + // Escape: clear filter or cancel + const kb = getEditorKeybindings(); + if (kb.matches(keyData, "selectCancel")) { + if (this.filterText) { + this.filterText = ""; + this.input.setValue(""); + this.applyFilter(); + } else { + this.onCancel?.(); + } + return; + } + + // All other input goes to filter + const prevValue = this.input.getValue(); + this.input.handleInput(keyData); + const newValue = this.input.getValue(); + + if (newValue !== prevValue) { + this.filterText = newValue; + this.applyFilter(); + } + } + + getSelectedItem(): SelectItem | null { + return this.selectList.getSelectedItem(); + } + + getFilterText(): string { + return this.filterText; + } +} diff --git a/src/tui/components/fuzzy-filter.ts b/src/tui/components/fuzzy-filter.ts new file mode 100644 index 000000000..76a688d3b --- /dev/null +++ b/src/tui/components/fuzzy-filter.ts @@ -0,0 +1,114 @@ +/** + * Shared fuzzy filtering utilities for select list components. + */ + +/** + * Word boundary characters for matching. + */ +const WORD_BOUNDARY_CHARS = /[\s\-_./:#@]/; + +/** + * Check if position is at a word boundary. + */ +export function isWordBoundary(text: string, index: number): boolean { + return index === 0 || WORD_BOUNDARY_CHARS.test(text[index - 1] ?? ""); +} + +/** + * Find index where query matches at a word boundary in text. + * Returns null if no match. + */ +export function findWordBoundaryIndex(text: string, query: string): number | null { + if (!query) return null; + const textLower = text.toLowerCase(); + const queryLower = query.toLowerCase(); + const maxIndex = textLower.length - queryLower.length; + if (maxIndex < 0) return null; + for (let i = 0; i <= maxIndex; i++) { + if (textLower.startsWith(queryLower, i) && isWordBoundary(textLower, i)) { + return i; + } + } + return null; +} + +/** + * Fuzzy match with pre-lowercased inputs (avoids toLowerCase on every keystroke). + * Returns score (lower = better) or null if no match. + */ +export function fuzzyMatchLower(queryLower: string, textLower: string): number | null { + if (queryLower.length === 0) return 0; + if (queryLower.length > textLower.length) return null; + + let queryIndex = 0; + let score = 0; + let lastMatchIndex = -1; + let consecutiveMatches = 0; + + for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) { + if (textLower[i] === queryLower[queryIndex]) { + const isAtWordBoundary = isWordBoundary(textLower, i); + if (lastMatchIndex === i - 1) { + consecutiveMatches++; + score -= consecutiveMatches * 5; // Reward consecutive matches + } else { + consecutiveMatches = 0; + if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2; // Penalize gaps + } + if (isAtWordBoundary) score -= 10; // Reward word boundary matches + score += i * 0.1; // Slight penalty for later matches + lastMatchIndex = i; + queryIndex++; + } + } + return queryIndex < queryLower.length ? null : score; +} + +/** + * Filter items using pre-lowercased searchTextLower field. + * Supports space-separated tokens (all must match). + */ +export function fuzzyFilterLower( + items: T[], + queryLower: string, +): T[] { + const trimmed = queryLower.trim(); + if (!trimmed) return items; + + const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0); + if (tokens.length === 0) return items; + + const results: { item: T; score: number }[] = []; + for (const item of items) { + const text = item.searchTextLower ?? ""; + let totalScore = 0; + let allMatch = true; + for (const token of tokens) { + const score = fuzzyMatchLower(token, text); + if (score !== null) { + totalScore += score; + } else { + allMatch = false; + break; + } + } + if (allMatch) results.push({ item, score: totalScore }); + } + results.sort((a, b) => a.score - b.score); + return results.map((r) => r.item); +} + +/** + * Prepare items for fuzzy filtering by pre-computing lowercase search text. + */ +export function prepareSearchItems( + items: T[], +): (T & { searchTextLower: string })[] { + return items.map((item) => { + const parts: string[] = []; + if (item.label) parts.push(item.label); + if (item.description) parts.push(item.description); + if (item.searchText) parts.push(item.searchText); + return { ...item, searchTextLower: parts.join(" ").toLowerCase() }; + }); +} diff --git a/src/tui/components/searchable-select-list.ts b/src/tui/components/searchable-select-list.ts index 46cd5fefe..37ff21ebc 100644 --- a/src/tui/components/searchable-select-list.ts +++ b/src/tui/components/searchable-select-list.ts @@ -1,6 +1,7 @@ import { type Component, fuzzyFilter, + getEditorKeybindings, Input, isKeyRelease, matchesKey, @@ -9,6 +10,7 @@ import { truncateToWidth, } from "@mariozechner/pi-tui"; import { visibleWidth } from "../../terminal/ansi.js"; +import { findWordBoundaryIndex } from "./fuzzy-filter.js"; export interface SearchableSelectListTheme extends SelectListTheme { searchPrompt: (text: string) => string; @@ -80,7 +82,7 @@ export class SearchableSelectList implements Component { continue; } // Tier 2: Word-boundary prefix in label (score 100-199) - const wordBoundaryIndex = this.findWordBoundaryIndex(label, q); + const wordBoundaryIndex = findWordBoundaryIndex(label, q); if (wordBoundaryIndex !== null) { wordBoundary.push({ item, score: wordBoundaryIndex }); continue; @@ -111,28 +113,6 @@ export class SearchableSelectList implements Component { ]; } - /** - * Check if query matches at a word boundary in text. - * E.g., "gpt" matches "openai/gpt-4" at the "gpt" word boundary. - */ - private matchesWordBoundary(text: string, query: string): boolean { - return this.findWordBoundaryIndex(text, query) !== null; - } - - private findWordBoundaryIndex(text: string, query: string): number | null { - if (!query) return null; - const maxIndex = text.length - query.length; - if (maxIndex < 0) return null; - for (let i = 0; i <= maxIndex; i++) { - if (text.startsWith(query, i)) { - if (i === 0 || /[\s\-_./:]/.test(text[i - 1] ?? "")) { - return i; - } - } - } - return null; - } - private escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } @@ -258,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")) { + 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")) { + 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; @@ -279,7 +269,8 @@ export class SearchableSelectList implements Component { return; } - if (matchesKey(keyData, "escape")) { + const kb = getEditorKeybindings(); + if (kb.matches(keyData, "selectCancel")) { if (this.onCancel) { this.onCancel(); } diff --git a/src/tui/components/selectors.ts b/src/tui/components/selectors.ts index f56d24e3b..ba37ff7c9 100644 --- a/src/tui/components/selectors.ts +++ b/src/tui/components/selectors.ts @@ -1,5 +1,14 @@ import { type SelectItem, SelectList, type SettingItem, SettingsList } from "@mariozechner/pi-tui"; -import { searchableSelectListTheme, selectListTheme, settingsListTheme } from "../theme/theme.js"; +import { + filterableSelectListTheme, + searchableSelectListTheme, + selectListTheme, + settingsListTheme, +} from "../theme/theme.js"; +import { + FilterableSelectList, + type FilterableSelectItem, +} from "./filterable-select-list.js"; import { SearchableSelectList } from "./searchable-select-list.js"; export function createSelectList(items: SelectItem[], maxVisible = 7) { @@ -10,6 +19,10 @@ export function createSearchableSelectList(items: SelectItem[], maxVisible = 7) return new SearchableSelectList(items, maxVisible, searchableSelectListTheme); } +export function createFilterableSelectList(items: FilterableSelectItem[], maxVisible = 7) { + return new FilterableSelectList(items, maxVisible, filterableSelectListTheme); +} + export function createSettingsList( items: SettingItem[], onChange: (id: string, value: string) => void, diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index 1ba09df49..580550347 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -64,6 +64,8 @@ export type GatewaySessionList = { lastProvider?: string; lastTo?: string; lastAccountId?: string; + derivedTitle?: string; + lastMessagePreview?: string; }>; }; @@ -183,6 +185,8 @@ export class GatewayChatClient { activeMinutes: opts?.activeMinutes, includeGlobal: opts?.includeGlobal, includeUnknown: opts?.includeUnknown, + includeDerivedTitles: opts?.includeDerivedTitles, + includeLastMessage: opts?.includeLastMessage, agentId: opts?.agentId, }); } diff --git a/src/tui/theme/theme.ts b/src/tui/theme/theme.ts index 78bb24981..18fa5f4d4 100644 --- a/src/tui/theme/theme.ts +++ b/src/tui/theme/theme.ts @@ -106,6 +106,11 @@ export const selectListTheme: SelectListTheme = { noMatch: (text) => fg(palette.dim)(text), }; +export const filterableSelectListTheme = { + ...selectListTheme, + filterLabel: (text: string) => fg(palette.dim)(text), +}; + export const settingsListTheme: SettingsListTheme = { label: (text, selected) => selected ? chalk.bold(fg(palette.accent)(text)) : fg(palette.text)(text), diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 485296dc3..40584da0e 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -5,9 +5,14 @@ import { resolveResponseUsageMode, } from "../auto-reply/thinking.js"; import { normalizeAgentId } from "../routing/session-key.js"; +import { formatRelativeTime } from "../utils/time-format.js"; import { helpText, parseCommand } from "./commands.js"; import type { ChatLog } from "./components/chat-log.js"; -import { createSearchableSelectList, createSettingsList } from "./components/selectors.js"; +import { + createFilterableSelectList, + createSearchableSelectList, + createSettingsList, +} from "./components/selectors.js"; import type { GatewayChatClient } from "./gateway-chat.js"; import { formatStatusSummary } from "./tui-status-summary.js"; import type { @@ -134,16 +139,37 @@ export function createCommandHandlers(context: CommandHandlerContext) { const result = await client.listSessions({ includeGlobal: false, includeUnknown: false, + includeDerivedTitles: true, + includeLastMessage: true, agentId: state.currentAgentId, }); - const items = result.sessions.map((session) => ({ - value: session.key, - label: session.displayName - ? `${session.displayName} (${formatSessionKey(session.key)})` - : formatSessionKey(session.key), - description: session.updatedAt ? new Date(session.updatedAt).toLocaleString() : "", - })); - const selector = createSearchableSelectList(items, 9); + const items = result.sessions.map((session) => { + const title = session.derivedTitle ?? session.displayName; + const formattedKey = formatSessionKey(session.key); + // Avoid redundant "title (key)" when title matches key + const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey; + // Build description: time + message preview + const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : ""; + const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim(); + const description = + timePart && preview ? `${timePart} · ${preview}` : (preview ?? timePart); + return { + value: session.key, + label, + description, + searchText: [ + session.displayName, + session.label, + session.subject, + session.sessionId, + session.key, + session.lastMessagePreview, + ] + .filter(Boolean) + .join(" "), + }; + }); + const selector = createFilterableSelectList(items, 9); selector.onSelect = (item) => { void (async () => { closeOverlay(); diff --git a/src/utils/time-format.ts b/src/utils/time-format.ts new file mode 100644 index 000000000..f5d4ee81b --- /dev/null +++ b/src/utils/time-format.ts @@ -0,0 +1,15 @@ +export function formatRelativeTime(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days === 1) return "Yesterday"; + if (days < 7) return `${days}d ago`; + return new Date(timestamp).toLocaleDateString(undefined, { month: "short", day: "numeric" }); +}