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:
@@ -63,6 +63,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
||||||
- Agents: clarify node_modules read-only guidance in agent instructions.
|
- Agents: clarify node_modules read-only guidance in agent instructions.
|
||||||
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
||||||
|
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) — thanks @Whoaa512.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
|
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
|
||||||
import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.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 { listDeliverableMessageChannels } from "../utils/message-channel.js";
|
||||||
import type { ResolvedTimeFormat } from "./date-time.js";
|
import type { ResolvedTimeFormat } from "./date-time.js";
|
||||||
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
|
import type { EmbeddedContextFile } from "./pi-embedded-helpers.js";
|
||||||
@@ -86,22 +85,8 @@ function buildMessagingSection(params: {
|
|||||||
messageChannelOptions: string;
|
messageChannelOptions: string;
|
||||||
inlineButtonsEnabled: boolean;
|
inlineButtonsEnabled: boolean;
|
||||||
runtimeChannel?: string;
|
runtimeChannel?: string;
|
||||||
channelActions?: string[];
|
|
||||||
}) {
|
}) {
|
||||||
if (params.isMinimal) return [];
|
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 [
|
return [
|
||||||
"## Messaging",
|
"## Messaging",
|
||||||
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
|
"- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
|
||||||
@@ -111,7 +96,7 @@ function buildMessagingSection(params: {
|
|||||||
? [
|
? [
|
||||||
"",
|
"",
|
||||||
"### message tool",
|
"### message tool",
|
||||||
actionsDescription,
|
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
|
||||||
"- For `action=send`, include `to` and `message`.",
|
"- For `action=send`, include `to` and `message`.",
|
||||||
`- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`,
|
`- 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).`,
|
`- 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",
|
"Community: https://discord.com/invite/clawd",
|
||||||
"Find new skills: https://clawdhub.com",
|
"Find new skills: https://clawdhub.com",
|
||||||
"For Clawdbot behavior, commands, config, or architecture: consult local docs first.",
|
"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;
|
arch?: string;
|
||||||
node?: string;
|
node?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
defaultModel?: string;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
capabilities?: string[];
|
capabilities?: string[];
|
||||||
/** Supported message actions for the current channel (e.g., react, edit, unsend) */
|
|
||||||
channelActions?: string[];
|
|
||||||
};
|
};
|
||||||
sandboxInfo?: {
|
sandboxInfo?: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -381,11 +365,11 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
"## Clawdbot CLI Quick Reference",
|
"## Clawdbot CLI Quick Reference",
|
||||||
"Clawdbot is controlled via subcommands. Do not invent commands.",
|
"Clawdbot is controlled via subcommands. Do not invent commands.",
|
||||||
"To manage the Gateway daemon service (start/stop/restart):",
|
"To manage the Gateway daemon service (start/stop/restart):",
|
||||||
`- ${formatCliCommand("clawdbot daemon status")}`,
|
"- clawdbot daemon status",
|
||||||
`- ${formatCliCommand("clawdbot daemon start")}`,
|
"- clawdbot daemon start",
|
||||||
`- ${formatCliCommand("clawdbot daemon stop")}`,
|
"- clawdbot daemon stop",
|
||||||
`- ${formatCliCommand("clawdbot daemon restart")}`,
|
"- clawdbot daemon restart",
|
||||||
`If unsure, ask the user to run \`${formatCliCommand("clawdbot help")}\` (or \`${formatCliCommand("clawdbot daemon --help")}\`) and paste the output.`,
|
"If unsure, ask the user to run `clawdbot help` (or `clawdbot daemon --help`) and paste the output.",
|
||||||
"",
|
"",
|
||||||
...skillsSection,
|
...skillsSection,
|
||||||
...memorySection,
|
...memorySection,
|
||||||
@@ -484,7 +468,6 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
messageChannelOptions,
|
messageChannelOptions,
|
||||||
inlineButtonsEnabled,
|
inlineButtonsEnabled,
|
||||||
runtimeChannel,
|
runtimeChannel,
|
||||||
channelActions: runtimeInfo?.channelActions,
|
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -3,81 +3,92 @@ import { Type } from "@sinclair/typebox";
|
|||||||
import { NonEmptyString, SessionLabelString } from "./primitives.js";
|
import { NonEmptyString, SessionLabelString } from "./primitives.js";
|
||||||
|
|
||||||
export const SessionsListParamsSchema = Type.Object(
|
export const SessionsListParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||||
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
|
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||||
includeGlobal: Type.Optional(Type.Boolean()),
|
includeGlobal: Type.Optional(Type.Boolean()),
|
||||||
includeUnknown: Type.Optional(Type.Boolean()),
|
includeUnknown: Type.Optional(Type.Boolean()),
|
||||||
label: Type.Optional(SessionLabelString),
|
/**
|
||||||
spawnedBy: Type.Optional(NonEmptyString),
|
* Read first 8KB of each session transcript to derive title from first user message.
|
||||||
agentId: Type.Optional(NonEmptyString),
|
* Performs a file read per session - use `limit` to bound result set on large stores.
|
||||||
},
|
*/
|
||||||
{ additionalProperties: false },
|
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(
|
export const SessionsResolveParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
key: Type.Optional(NonEmptyString),
|
key: Type.Optional(NonEmptyString),
|
||||||
label: Type.Optional(SessionLabelString),
|
label: Type.Optional(SessionLabelString),
|
||||||
agentId: Type.Optional(NonEmptyString),
|
agentId: Type.Optional(NonEmptyString),
|
||||||
spawnedBy: Type.Optional(NonEmptyString),
|
spawnedBy: Type.Optional(NonEmptyString),
|
||||||
includeGlobal: Type.Optional(Type.Boolean()),
|
includeGlobal: Type.Optional(Type.Boolean()),
|
||||||
includeUnknown: Type.Optional(Type.Boolean()),
|
includeUnknown: Type.Optional(Type.Boolean()),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SessionsPatchParamsSchema = Type.Object(
|
export const SessionsPatchParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
key: NonEmptyString,
|
key: NonEmptyString,
|
||||||
label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
|
label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
|
||||||
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
reasoningLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
responseUsage: Type.Optional(
|
responseUsage: Type.Optional(
|
||||||
Type.Union([
|
Type.Union([
|
||||||
Type.Literal("off"),
|
Type.Literal("off"),
|
||||||
Type.Literal("tokens"),
|
Type.Literal("tokens"),
|
||||||
Type.Literal("full"),
|
Type.Literal("full"),
|
||||||
// Backward compat with older clients/stores.
|
// Backward compat with older clients/stores.
|
||||||
Type.Literal("on"),
|
Type.Literal("on"),
|
||||||
Type.Null(),
|
Type.Null(),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
elevatedLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
execHost: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
execSecurity: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
sendPolicy: Type.Optional(
|
sendPolicy: Type.Optional(
|
||||||
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
|
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
|
||||||
),
|
),
|
||||||
groupActivation: Type.Optional(
|
groupActivation: Type.Optional(
|
||||||
Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]),
|
Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SessionsResetParamsSchema = Type.Object(
|
export const SessionsResetParamsSchema = Type.Object(
|
||||||
{ key: NonEmptyString },
|
{ key: NonEmptyString },
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SessionsDeleteParamsSchema = Type.Object(
|
export const SessionsDeleteParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
key: NonEmptyString,
|
key: NonEmptyString,
|
||||||
deleteTranscript: Type.Optional(Type.Boolean()),
|
deleteTranscript: Type.Optional(Type.Boolean()),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const SessionsCompactParamsSchema = Type.Object(
|
export const SessionsCompactParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
key: NonEmptyString,
|
key: NonEmptyString,
|
||||||
maxLines: Type.Optional(Type.Integer({ minimum: 1 })),
|
maxLines: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|||||||
343
src/gateway/session-utils.fs.test.ts
Normal file
343
src/gateway/session-utils.fs.test.ts
Normal 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: 你好世界 🌍");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -79,3 +79,113 @@ export function capArrayByJsonBytes<T>(
|
|||||||
const next = start > 0 ? items.slice(start) : items;
|
const next = start > 0 ? items.slice(start) : items;
|
||||||
return { items: next, bytes };
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type { SessionEntry } from "../config/sessions.js";
|
|||||||
import {
|
import {
|
||||||
capArrayByJsonBytes,
|
capArrayByJsonBytes,
|
||||||
classifySessionKey,
|
classifySessionKey,
|
||||||
|
deriveSessionTitle,
|
||||||
|
listSessionsFromStore,
|
||||||
parseGroupKey,
|
parseGroupKey,
|
||||||
resolveGatewaySessionStoreTarget,
|
resolveGatewaySessionStoreTarget,
|
||||||
resolveSessionStoreKey,
|
resolveSessionStoreKey,
|
||||||
@@ -91,3 +93,242 @@ describe("gateway session utils", () => {
|
|||||||
expect(target.storePath).toBe(path.resolve(storeTemplate.replace("{agentId}", "ops")));
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import {
|
|||||||
parseAgentSessionKey,
|
parseAgentSessionKey,
|
||||||
} from "../routing/session-key.js";
|
} from "../routing/session-key.js";
|
||||||
import { normalizeSessionDeliveryFields } from "../utils/delivery-context.js";
|
import { normalizeSessionDeliveryFields } from "../utils/delivery-context.js";
|
||||||
|
import {
|
||||||
|
readFirstUserMessageFromTranscript,
|
||||||
|
readLastMessagePreviewFromTranscript,
|
||||||
|
} from "./session-utils.fs.js";
|
||||||
import type {
|
import type {
|
||||||
GatewayAgentRow,
|
GatewayAgentRow,
|
||||||
GatewaySessionRow,
|
GatewaySessionRow,
|
||||||
@@ -32,6 +36,8 @@ import type {
|
|||||||
export {
|
export {
|
||||||
archiveFileOnDisk,
|
archiveFileOnDisk,
|
||||||
capArrayByJsonBytes,
|
capArrayByJsonBytes,
|
||||||
|
readFirstUserMessageFromTranscript,
|
||||||
|
readLastMessagePreviewFromTranscript,
|
||||||
readSessionMessages,
|
readSessionMessages,
|
||||||
resolveSessionTranscriptCandidates,
|
resolveSessionTranscriptCandidates,
|
||||||
} from "./session-utils.fs.js";
|
} from "./session-utils.fs.js";
|
||||||
@@ -43,6 +49,52 @@ export type {
|
|||||||
SessionsPatchResult,
|
SessionsPatchResult,
|
||||||
} from "./session-utils.types.js";
|
} 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) {
|
export function loadSessionEntry(sessionKey: string) {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const sessionCfg = cfg.session;
|
const sessionCfg = cfg.session;
|
||||||
@@ -341,9 +393,12 @@ export function listSessionsFromStore(params: {
|
|||||||
|
|
||||||
const includeGlobal = opts.includeGlobal === true;
|
const includeGlobal = opts.includeGlobal === true;
|
||||||
const includeUnknown = opts.includeUnknown === 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 spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
|
||||||
const label = typeof opts.label === "string" ? opts.label.trim() : "";
|
const label = typeof opts.label === "string" ? opts.label.trim() : "";
|
||||||
const agentId = typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : "";
|
const agentId = typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : "";
|
||||||
|
const search = typeof opts.search === "string" ? opts.search.trim().toLowerCase() : "";
|
||||||
const activeMinutes =
|
const activeMinutes =
|
||||||
typeof opts.activeMinutes === "number" && Number.isFinite(opts.activeMinutes)
|
typeof opts.activeMinutes === "number" && Number.isFinite(opts.activeMinutes)
|
||||||
? Math.max(1, Math.floor(opts.activeMinutes))
|
? Math.max(1, Math.floor(opts.activeMinutes))
|
||||||
@@ -400,6 +455,7 @@ export function listSessionsFromStore(params: {
|
|||||||
const deliveryFields = normalizeSessionDeliveryFields(entry);
|
const deliveryFields = normalizeSessionDeliveryFields(entry);
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
|
entry,
|
||||||
kind: classifySessionKey(key, entry),
|
kind: classifySessionKey(key, entry),
|
||||||
label: entry?.label,
|
label: entry?.label,
|
||||||
displayName,
|
displayName,
|
||||||
@@ -429,10 +485,17 @@ export function listSessionsFromStore(params: {
|
|||||||
lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel,
|
lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel,
|
||||||
lastTo: deliveryFields.lastTo ?? entry?.lastTo,
|
lastTo: deliveryFields.lastTo ?? entry?.lastTo,
|
||||||
lastAccountId: deliveryFields.lastAccountId ?? entry?.lastAccountId,
|
lastAccountId: deliveryFields.lastAccountId ?? entry?.lastAccountId,
|
||||||
} satisfies GatewaySessionRow;
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
.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) {
|
if (activeMinutes !== undefined) {
|
||||||
const cutoff = now - activeMinutes * 60_000;
|
const cutoff = now - activeMinutes * 60_000;
|
||||||
sessions = sessions.filter((s) => (s.updatedAt ?? 0) >= cutoff);
|
sessions = sessions.filter((s) => (s.updatedAt ?? 0) >= cutoff);
|
||||||
@@ -443,11 +506,36 @@ export function listSessionsFromStore(params: {
|
|||||||
sessions = sessions.slice(0, limit);
|
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 {
|
return {
|
||||||
ts: now,
|
ts: now,
|
||||||
path: storePath,
|
path: storePath,
|
||||||
count: sessions.length,
|
count: finalSessions.length,
|
||||||
defaults: getSessionDefaults(cfg),
|
defaults: getSessionDefaults(cfg),
|
||||||
sessions,
|
sessions: finalSessions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export type GatewaySessionRow = {
|
|||||||
kind: "direct" | "group" | "global" | "unknown";
|
kind: "direct" | "group" | "global" | "unknown";
|
||||||
label?: string;
|
label?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
|
derivedTitle?: string;
|
||||||
|
lastMessagePreview?: string;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
subject?: string;
|
subject?: string;
|
||||||
groupChannel?: string;
|
groupChannel?: string;
|
||||||
|
|||||||
143
src/tui/components/filterable-select-list.ts
Normal file
143
src/tui/components/filterable-select-list.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/tui/components/fuzzy-filter.ts
Normal file
114
src/tui/components/fuzzy-filter.ts
Normal 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() };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
type Component,
|
type Component,
|
||||||
fuzzyFilter,
|
fuzzyFilter,
|
||||||
|
getEditorKeybindings,
|
||||||
Input,
|
Input,
|
||||||
isKeyRelease,
|
isKeyRelease,
|
||||||
matchesKey,
|
matchesKey,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
truncateToWidth,
|
truncateToWidth,
|
||||||
} from "@mariozechner/pi-tui";
|
} from "@mariozechner/pi-tui";
|
||||||
import { visibleWidth } from "../../terminal/ansi.js";
|
import { visibleWidth } from "../../terminal/ansi.js";
|
||||||
|
import { findWordBoundaryIndex } from "./fuzzy-filter.js";
|
||||||
|
|
||||||
export interface SearchableSelectListTheme extends SelectListTheme {
|
export interface SearchableSelectListTheme extends SelectListTheme {
|
||||||
searchPrompt: (text: string) => string;
|
searchPrompt: (text: string) => string;
|
||||||
@@ -80,7 +82,7 @@ export class SearchableSelectList implements Component {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Tier 2: Word-boundary prefix in label (score 100-199)
|
// Tier 2: Word-boundary prefix in label (score 100-199)
|
||||||
const wordBoundaryIndex = this.findWordBoundaryIndex(label, q);
|
const wordBoundaryIndex = findWordBoundaryIndex(label, q);
|
||||||
if (wordBoundaryIndex !== null) {
|
if (wordBoundaryIndex !== null) {
|
||||||
wordBoundary.push({ item, score: wordBoundaryIndex });
|
wordBoundary.push({ item, score: wordBoundaryIndex });
|
||||||
continue;
|
continue;
|
||||||
@@ -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 {
|
private escapeRegex(str: string): string {
|
||||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
@@ -258,14 +238,24 @@ export class SearchableSelectList implements Component {
|
|||||||
handleInput(keyData: string): void {
|
handleInput(keyData: string): void {
|
||||||
if (isKeyRelease(keyData)) return;
|
if (isKeyRelease(keyData)) return;
|
||||||
|
|
||||||
|
const allowVimNav = !this.searchInput.getValue().trim();
|
||||||
|
|
||||||
// Navigation keys
|
// Navigation keys
|
||||||
if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p")) {
|
if (
|
||||||
|
matchesKey(keyData, "up") ||
|
||||||
|
matchesKey(keyData, "ctrl+p") ||
|
||||||
|
(allowVimNav && keyData === "k")
|
||||||
|
) {
|
||||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||||
this.notifySelectionChange();
|
this.notifySelectionChange();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n")) {
|
if (
|
||||||
|
matchesKey(keyData, "down") ||
|
||||||
|
matchesKey(keyData, "ctrl+n") ||
|
||||||
|
(allowVimNav && keyData === "j")
|
||||||
|
) {
|
||||||
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
|
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
|
||||||
this.notifySelectionChange();
|
this.notifySelectionChange();
|
||||||
return;
|
return;
|
||||||
@@ -279,7 +269,8 @@ export class SearchableSelectList implements Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchesKey(keyData, "escape")) {
|
const kb = getEditorKeybindings();
|
||||||
|
if (kb.matches(keyData, "selectCancel")) {
|
||||||
if (this.onCancel) {
|
if (this.onCancel) {
|
||||||
this.onCancel();
|
this.onCancel();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { type SelectItem, SelectList, type SettingItem, SettingsList } from "@mariozechner/pi-tui";
|
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";
|
import { SearchableSelectList } from "./searchable-select-list.js";
|
||||||
|
|
||||||
export function createSelectList(items: SelectItem[], maxVisible = 7) {
|
export function createSelectList(items: SelectItem[], maxVisible = 7) {
|
||||||
@@ -10,6 +19,10 @@ export function createSearchableSelectList(items: SelectItem[], maxVisible = 7)
|
|||||||
return new SearchableSelectList(items, maxVisible, searchableSelectListTheme);
|
return new SearchableSelectList(items, maxVisible, searchableSelectListTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createFilterableSelectList(items: FilterableSelectItem[], maxVisible = 7) {
|
||||||
|
return new FilterableSelectList(items, maxVisible, filterableSelectListTheme);
|
||||||
|
}
|
||||||
|
|
||||||
export function createSettingsList(
|
export function createSettingsList(
|
||||||
items: SettingItem[],
|
items: SettingItem[],
|
||||||
onChange: (id: string, value: string) => void,
|
onChange: (id: string, value: string) => void,
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export type GatewaySessionList = {
|
|||||||
lastProvider?: string;
|
lastProvider?: string;
|
||||||
lastTo?: string;
|
lastTo?: string;
|
||||||
lastAccountId?: string;
|
lastAccountId?: string;
|
||||||
|
derivedTitle?: string;
|
||||||
|
lastMessagePreview?: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -183,6 +185,8 @@ export class GatewayChatClient {
|
|||||||
activeMinutes: opts?.activeMinutes,
|
activeMinutes: opts?.activeMinutes,
|
||||||
includeGlobal: opts?.includeGlobal,
|
includeGlobal: opts?.includeGlobal,
|
||||||
includeUnknown: opts?.includeUnknown,
|
includeUnknown: opts?.includeUnknown,
|
||||||
|
includeDerivedTitles: opts?.includeDerivedTitles,
|
||||||
|
includeLastMessage: opts?.includeLastMessage,
|
||||||
agentId: opts?.agentId,
|
agentId: opts?.agentId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ export const selectListTheme: SelectListTheme = {
|
|||||||
noMatch: (text) => fg(palette.dim)(text),
|
noMatch: (text) => fg(palette.dim)(text),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const filterableSelectListTheme = {
|
||||||
|
...selectListTheme,
|
||||||
|
filterLabel: (text: string) => fg(palette.dim)(text),
|
||||||
|
};
|
||||||
|
|
||||||
export const settingsListTheme: SettingsListTheme = {
|
export const settingsListTheme: SettingsListTheme = {
|
||||||
label: (text, selected) =>
|
label: (text, selected) =>
|
||||||
selected ? chalk.bold(fg(palette.accent)(text)) : fg(palette.text)(text),
|
selected ? chalk.bold(fg(palette.accent)(text)) : fg(palette.text)(text),
|
||||||
|
|||||||
@@ -5,9 +5,14 @@ import {
|
|||||||
resolveResponseUsageMode,
|
resolveResponseUsageMode,
|
||||||
} from "../auto-reply/thinking.js";
|
} from "../auto-reply/thinking.js";
|
||||||
import { normalizeAgentId } from "../routing/session-key.js";
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
|
import { formatRelativeTime } from "../utils/time-format.js";
|
||||||
import { helpText, parseCommand } from "./commands.js";
|
import { helpText, parseCommand } from "./commands.js";
|
||||||
import type { ChatLog } from "./components/chat-log.js";
|
import type { ChatLog } from "./components/chat-log.js";
|
||||||
import { createSearchableSelectList, createSettingsList } from "./components/selectors.js";
|
import {
|
||||||
|
createFilterableSelectList,
|
||||||
|
createSearchableSelectList,
|
||||||
|
createSettingsList,
|
||||||
|
} from "./components/selectors.js";
|
||||||
import type { GatewayChatClient } from "./gateway-chat.js";
|
import type { GatewayChatClient } from "./gateway-chat.js";
|
||||||
import { formatStatusSummary } from "./tui-status-summary.js";
|
import { formatStatusSummary } from "./tui-status-summary.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -134,16 +139,37 @@ export function createCommandHandlers(context: CommandHandlerContext) {
|
|||||||
const result = await client.listSessions({
|
const result = await client.listSessions({
|
||||||
includeGlobal: false,
|
includeGlobal: false,
|
||||||
includeUnknown: false,
|
includeUnknown: false,
|
||||||
|
includeDerivedTitles: true,
|
||||||
|
includeLastMessage: true,
|
||||||
agentId: state.currentAgentId,
|
agentId: state.currentAgentId,
|
||||||
});
|
});
|
||||||
const items = result.sessions.map((session) => ({
|
const items = result.sessions.map((session) => {
|
||||||
value: session.key,
|
const title = session.derivedTitle ?? session.displayName;
|
||||||
label: session.displayName
|
const formattedKey = formatSessionKey(session.key);
|
||||||
? `${session.displayName} (${formatSessionKey(session.key)})`
|
// Avoid redundant "title (key)" when title matches key
|
||||||
: formatSessionKey(session.key),
|
const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey;
|
||||||
description: session.updatedAt ? new Date(session.updatedAt).toLocaleString() : "",
|
// Build description: time + message preview
|
||||||
}));
|
const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : "";
|
||||||
const selector = createSearchableSelectList(items, 9);
|
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) => {
|
selector.onSelect = (item) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
closeOverlay();
|
closeOverlay();
|
||||||
|
|||||||
15
src/utils/time-format.ts
Normal file
15
src/utils/time-format.ts
Normal 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" });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user