Merge pull request #1271 from Whoaa512/feat/session-picker-mvp

feat: session picker MVP - fuzzy search, derived titles, relative time
This commit is contained in:
Peter Steinberger
2026-01-20 16:46:48 +00:00
committed by GitHub
16 changed files with 1216 additions and 126 deletions

View File

@@ -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,
}),
];

View File

@@ -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 },
);

View File

@@ -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: 你好世界 🌍");
});
});

View File

@@ -79,3 +79,113 @@ export function capArrayByJsonBytes<T>(
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;
}

View File

@@ -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<string, SessionEntry> => ({
"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);
});
});

View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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<T extends { searchTextLower?: string }>(
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<T extends { label?: string; description?: string; searchText?: string }>(
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() };
});
}

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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,
});
}

View File

@@ -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),

View File

@@ -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();

15
src/utils/time-format.ts Normal file
View File

@@ -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" });
}