fix: polish session picker filtering (#1271) (thanks @Whoaa512)
This commit is contained in:
@@ -63,7 +63,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
||||||
- Agents: clarify node_modules read-only guidance in agent instructions.
|
- Agents: clarify node_modules read-only guidance in agent instructions.
|
||||||
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
||||||
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview.
|
- TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) — thanks @Whoaa512.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,308 +3,341 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
import {
|
import {
|
||||||
readFirstUserMessageFromTranscript,
|
readFirstUserMessageFromTranscript,
|
||||||
readLastMessagePreviewFromTranscript,
|
readLastMessagePreviewFromTranscript,
|
||||||
} from "./session-utils.fs.js";
|
} from "./session-utils.fs.js";
|
||||||
|
|
||||||
describe("readFirstUserMessageFromTranscript", () => {
|
describe("readFirstUserMessageFromTranscript", () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let storePath: string;
|
let storePath: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-"));
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-"));
|
||||||
storePath = path.join(tmpDir, "sessions.json");
|
storePath = path.join(tmpDir, "sessions.json");
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns null when transcript file does not exist", () => {
|
test("returns null when transcript file does not exist", () => {
|
||||||
const result = readFirstUserMessageFromTranscript("nonexistent-session", storePath);
|
const result = readFirstUserMessageFromTranscript("nonexistent-session", storePath);
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns first user message from transcript with string content", () => {
|
test("returns first user message from transcript with string content", () => {
|
||||||
const sessionId = "test-session-1";
|
const sessionId = "test-session-1";
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
const lines = [
|
const lines = [
|
||||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||||
JSON.stringify({ message: { role: "user", content: "Hello world" } }),
|
JSON.stringify({ message: { role: "user", content: "Hello world" } }),
|
||||||
JSON.stringify({ message: { role: "assistant", content: "Hi there" } }),
|
JSON.stringify({ message: { role: "assistant", content: "Hi there" } }),
|
||||||
];
|
];
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||||
expect(result).toBe("Hello world");
|
expect(result).toBe("Hello world");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns first user message from transcript with array content", () => {
|
test("returns first user message from transcript with array content", () => {
|
||||||
const sessionId = "test-session-2";
|
const sessionId = "test-session-2";
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
const lines = [
|
const lines = [
|
||||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
message: {
|
message: {
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [{ type: "text", text: "Array message content" }],
|
content: [{ type: "text", text: "Array message content" }],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||||
expect(result).toBe("Array message content");
|
expect(result).toBe("Array message content");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("skips non-user messages to find first user message", () => {
|
test("returns first user message from transcript with input_text content", () => {
|
||||||
const sessionId = "test-session-3";
|
const sessionId = "test-session-2b";
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
const lines = [
|
const lines = [
|
||||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||||
JSON.stringify({ message: { role: "system", content: "System prompt" } }),
|
JSON.stringify({
|
||||||
JSON.stringify({ message: { role: "assistant", content: "Greeting" } }),
|
message: {
|
||||||
JSON.stringify({ message: { role: "user", content: "First user question" } }),
|
role: "user",
|
||||||
];
|
content: [{ type: "input_text", text: "Input text content" }],
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||||
expect(result).toBe("First user question");
|
expect(result).toBe("Input text content");
|
||||||
});
|
});
|
||||||
|
test("skips non-user messages to find first user message", () => {
|
||||||
|
const sessionId = "test-session-3";
|
||||||
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||||
|
JSON.stringify({ message: { role: "system", content: "System prompt" } }),
|
||||||
|
JSON.stringify({ message: { role: "assistant", content: "Greeting" } }),
|
||||||
|
JSON.stringify({ message: { role: "user", content: "First user question" } }),
|
||||||
|
];
|
||||||
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
test("returns null when no user messages exist", () => {
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||||
const sessionId = "test-session-4";
|
expect(result).toBe("First user question");
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
});
|
||||||
const lines = [
|
|
||||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
|
||||||
JSON.stringify({ message: { role: "system", content: "System prompt" } }),
|
|
||||||
JSON.stringify({ message: { role: "assistant", content: "Greeting" } }),
|
|
||||||
];
|
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
||||||
|
|
||||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
test("returns null when no user messages exist", () => {
|
||||||
expect(result).toBeNull();
|
const sessionId = "test-session-4";
|
||||||
});
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||||
|
JSON.stringify({ message: { role: "system", content: "System prompt" } }),
|
||||||
|
JSON.stringify({ message: { role: "assistant", content: "Greeting" } }),
|
||||||
|
];
|
||||||
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
test("handles malformed JSON lines gracefully", () => {
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||||
const sessionId = "test-session-5";
|
expect(result).toBeNull();
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
});
|
||||||
const lines = [
|
|
||||||
"not valid json",
|
|
||||||
JSON.stringify({ message: { role: "user", content: "Valid message" } }),
|
|
||||||
];
|
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
||||||
|
|
||||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
test("handles malformed JSON lines gracefully", () => {
|
||||||
expect(result).toBe("Valid message");
|
const sessionId = "test-session-5";
|
||||||
});
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
|
const lines = [
|
||||||
|
"not valid json",
|
||||||
|
JSON.stringify({ message: { role: "user", content: "Valid message" } }),
|
||||||
|
];
|
||||||
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
test("uses sessionFile parameter when provided", () => {
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||||
const sessionId = "test-session-6";
|
expect(result).toBe("Valid message");
|
||||||
const customPath = path.join(tmpDir, "custom-transcript.jsonl");
|
});
|
||||||
const lines = [
|
|
||||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
|
||||||
JSON.stringify({ message: { role: "user", content: "Custom file message" } }),
|
|
||||||
];
|
|
||||||
fs.writeFileSync(customPath, lines.join("\n"), "utf-8");
|
|
||||||
|
|
||||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath, customPath);
|
test("uses sessionFile parameter when provided", () => {
|
||||||
expect(result).toBe("Custom file message");
|
const sessionId = "test-session-6";
|
||||||
});
|
const customPath = path.join(tmpDir, "custom-transcript.jsonl");
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||||
|
JSON.stringify({ message: { role: "user", content: "Custom file message" } }),
|
||||||
|
];
|
||||||
|
fs.writeFileSync(customPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
test("trims whitespace from message content", () => {
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath, customPath);
|
||||||
const sessionId = "test-session-7";
|
expect(result).toBe("Custom file message");
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
});
|
||||||
const lines = [JSON.stringify({ message: { role: "user", content: " Padded message " } })];
|
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
||||||
|
|
||||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
test("trims whitespace from message content", () => {
|
||||||
expect(result).toBe("Padded message");
|
const sessionId = "test-session-7";
|
||||||
});
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
|
const lines = [JSON.stringify({ message: { role: "user", content: " Padded message " } })];
|
||||||
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
test("returns null for empty content", () => {
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||||
const sessionId = "test-session-8";
|
expect(result).toBe("Padded message");
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
});
|
||||||
const lines = [
|
|
||||||
JSON.stringify({ message: { role: "user", content: "" } }),
|
|
||||||
JSON.stringify({ message: { role: "user", content: "Second message" } }),
|
|
||||||
];
|
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
||||||
|
|
||||||
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
test("returns null for empty content", () => {
|
||||||
expect(result).toBe("Second message");
|
const sessionId = "test-session-8";
|
||||||
});
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ message: { role: "user", content: "" } }),
|
||||||
|
JSON.stringify({ message: { role: "user", content: "Second message" } }),
|
||||||
|
];
|
||||||
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
|
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
|
||||||
|
expect(result).toBe("Second message");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("readLastMessagePreviewFromTranscript", () => {
|
describe("readLastMessagePreviewFromTranscript", () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let storePath: string;
|
let storePath: string;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-"));
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-fs-test-"));
|
||||||
storePath = path.join(tmpDir, "sessions.json");
|
storePath = path.join(tmpDir, "sessions.json");
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns null when transcript file does not exist", () => {
|
test("returns null when transcript file does not exist", () => {
|
||||||
const result = readLastMessagePreviewFromTranscript("nonexistent-session", storePath);
|
const result = readLastMessagePreviewFromTranscript("nonexistent-session", storePath);
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns null for empty file", () => {
|
test("returns null for empty file", () => {
|
||||||
const sessionId = "test-last-empty";
|
const sessionId = "test-last-empty";
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
fs.writeFileSync(transcriptPath, "", "utf-8");
|
fs.writeFileSync(transcriptPath, "", "utf-8");
|
||||||
|
|
||||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns last user message from transcript", () => {
|
test("returns last user message from transcript", () => {
|
||||||
const sessionId = "test-last-user";
|
const sessionId = "test-last-user";
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
const lines = [
|
const lines = [
|
||||||
JSON.stringify({ message: { role: "user", content: "First user" } }),
|
JSON.stringify({ message: { role: "user", content: "First user" } }),
|
||||||
JSON.stringify({ message: { role: "assistant", content: "First assistant" } }),
|
JSON.stringify({ message: { role: "assistant", content: "First assistant" } }),
|
||||||
JSON.stringify({ message: { role: "user", content: "Last user message" } }),
|
JSON.stringify({ message: { role: "user", content: "Last user message" } }),
|
||||||
];
|
];
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||||
expect(result).toBe("Last user message");
|
expect(result).toBe("Last user message");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns last assistant message from transcript", () => {
|
test("returns last assistant message from transcript", () => {
|
||||||
const sessionId = "test-last-assistant";
|
const sessionId = "test-last-assistant";
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
const lines = [
|
const lines = [
|
||||||
JSON.stringify({ message: { role: "user", content: "User question" } }),
|
JSON.stringify({ message: { role: "user", content: "User question" } }),
|
||||||
JSON.stringify({ message: { role: "assistant", content: "Final assistant reply" } }),
|
JSON.stringify({ message: { role: "assistant", content: "Final assistant reply" } }),
|
||||||
];
|
];
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||||
expect(result).toBe("Final assistant reply");
|
expect(result).toBe("Final assistant reply");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("skips system messages to find last user/assistant", () => {
|
test("skips system messages to find last user/assistant", () => {
|
||||||
const sessionId = "test-last-skip-system";
|
const sessionId = "test-last-skip-system";
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
const lines = [
|
const lines = [
|
||||||
JSON.stringify({ message: { role: "user", content: "Real last" } }),
|
JSON.stringify({ message: { role: "user", content: "Real last" } }),
|
||||||
JSON.stringify({ message: { role: "system", content: "System at end" } }),
|
JSON.stringify({ message: { role: "system", content: "System at end" } }),
|
||||||
];
|
];
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||||
expect(result).toBe("Real last");
|
expect(result).toBe("Real last");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("returns null when no user/assistant messages exist", () => {
|
test("returns null when no user/assistant messages exist", () => {
|
||||||
const sessionId = "test-last-no-match";
|
const sessionId = "test-last-no-match";
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
const lines = [
|
const lines = [
|
||||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||||
JSON.stringify({ message: { role: "system", content: "Only system" } }),
|
JSON.stringify({ message: { role: "system", content: "Only system" } }),
|
||||||
];
|
];
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles malformed JSON lines gracefully", () => {
|
test("handles malformed JSON lines gracefully", () => {
|
||||||
const sessionId = "test-last-malformed";
|
const sessionId = "test-last-malformed";
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
const lines = [
|
const lines = [
|
||||||
JSON.stringify({ message: { role: "user", content: "Valid first" } }),
|
JSON.stringify({ message: { role: "user", content: "Valid first" } }),
|
||||||
"not valid json at end",
|
"not valid json at end",
|
||||||
];
|
];
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||||
expect(result).toBe("Valid first");
|
expect(result).toBe("Valid first");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles array content format", () => {
|
test("handles array content format", () => {
|
||||||
const sessionId = "test-last-array";
|
const sessionId = "test-last-array";
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
const lines = [
|
const lines = [
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
message: {
|
message: {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: [{ type: "text", text: "Array content response" }],
|
content: [{ type: "text", text: "Array content response" }],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||||
expect(result).toBe("Array content response");
|
expect(result).toBe("Array content response");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses sessionFile parameter when provided", () => {
|
test("handles output_text content format", () => {
|
||||||
const sessionId = "test-last-custom";
|
const sessionId = "test-last-output-text";
|
||||||
const customPath = path.join(tmpDir, "custom-last.jsonl");
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
const lines = [JSON.stringify({ message: { role: "user", content: "Custom file last" } })];
|
const lines = [
|
||||||
fs.writeFileSync(customPath, lines.join("\n"), "utf-8");
|
JSON.stringify({
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "output_text", text: "Output text response" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath, customPath);
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||||
expect(result).toBe("Custom file last");
|
expect(result).toBe("Output text response");
|
||||||
});
|
});
|
||||||
|
test("uses sessionFile parameter when provided", () => {
|
||||||
|
const sessionId = "test-last-custom";
|
||||||
|
const customPath = path.join(tmpDir, "custom-last.jsonl");
|
||||||
|
const lines = [JSON.stringify({ message: { role: "user", content: "Custom file last" } })];
|
||||||
|
fs.writeFileSync(customPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
test("trims whitespace from message content", () => {
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath, customPath);
|
||||||
const sessionId = "test-last-trim";
|
expect(result).toBe("Custom file last");
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
});
|
||||||
const lines = [
|
|
||||||
JSON.stringify({ message: { role: "assistant", content: " Padded response " } }),
|
|
||||||
];
|
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
||||||
|
|
||||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
test("trims whitespace from message content", () => {
|
||||||
expect(result).toBe("Padded response");
|
const sessionId = "test-last-trim";
|
||||||
});
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ message: { role: "assistant", content: " Padded response " } }),
|
||||||
|
];
|
||||||
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
test("skips empty content to find previous message", () => {
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||||
const sessionId = "test-last-skip-empty";
|
expect(result).toBe("Padded response");
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
});
|
||||||
const lines = [
|
|
||||||
JSON.stringify({ message: { role: "assistant", content: "Has content" } }),
|
|
||||||
JSON.stringify({ message: { role: "user", content: "" } }),
|
|
||||||
];
|
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
||||||
|
|
||||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
test("skips empty content to find previous message", () => {
|
||||||
expect(result).toBe("Has content");
|
const sessionId = "test-last-skip-empty";
|
||||||
});
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ message: { role: "assistant", content: "Has content" } }),
|
||||||
|
JSON.stringify({ message: { role: "user", content: "" } }),
|
||||||
|
];
|
||||||
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
test("reads from end of large file (16KB window)", () => {
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||||
const sessionId = "test-last-large";
|
expect(result).toBe("Has content");
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
});
|
||||||
const padding = JSON.stringify({ message: { role: "user", content: "x".repeat(500) } });
|
|
||||||
const lines: string[] = [];
|
|
||||||
for (let i = 0; i < 50; i++) {
|
|
||||||
lines.push(padding);
|
|
||||||
}
|
|
||||||
lines.push(JSON.stringify({ message: { role: "assistant", content: "Last in large file" } }));
|
|
||||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
|
||||||
|
|
||||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
test("reads from end of large file (16KB window)", () => {
|
||||||
expect(result).toBe("Last in large file");
|
const sessionId = "test-last-large";
|
||||||
});
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
|
const padding = JSON.stringify({ message: { role: "user", content: "x".repeat(500) } });
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
lines.push(padding);
|
||||||
|
}
|
||||||
|
lines.push(JSON.stringify({ message: { role: "assistant", content: "Last in large file" } }));
|
||||||
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
test("handles valid UTF-8 content", () => {
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||||
const sessionId = "test-last-utf8";
|
expect(result).toBe("Last in large file");
|
||||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
});
|
||||||
const validLine = JSON.stringify({
|
|
||||||
message: { role: "user", content: "Valid UTF-8: 你好世界 🌍" },
|
|
||||||
});
|
|
||||||
fs.writeFileSync(transcriptPath, validLine, "utf-8");
|
|
||||||
|
|
||||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
test("handles valid UTF-8 content", () => {
|
||||||
expect(result).toBe("Valid UTF-8: 你好世界 🌍");
|
const sessionId = "test-last-utf8";
|
||||||
});
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
|
const validLine = JSON.stringify({
|
||||||
|
message: { role: "user", content: "Valid UTF-8: 你好世界 🌍" },
|
||||||
|
});
|
||||||
|
fs.writeFileSync(transcriptPath, validLine, "utf-8");
|
||||||
|
|
||||||
|
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||||
|
expect(result).toBe("Valid UTF-8: 你好世界 🌍");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -91,8 +91,10 @@ function extractTextFromContent(content: TranscriptMessage["content"]): string |
|
|||||||
if (typeof content === "string") return content.trim() || null;
|
if (typeof content === "string") return content.trim() || null;
|
||||||
if (!Array.isArray(content)) return null;
|
if (!Array.isArray(content)) return null;
|
||||||
for (const part of content) {
|
for (const part of content) {
|
||||||
if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
|
if (!part || typeof part.text !== "string") continue;
|
||||||
return part.text.trim();
|
if (part.type === "text" || part.type === "output_text" || part.type === "input_text") {
|
||||||
|
const trimmed = part.text.trim();
|
||||||
|
if (trimmed) return trimmed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -84,7 +84,8 @@ export function deriveSessionTitle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (firstUserMessage?.trim()) {
|
if (firstUserMessage?.trim()) {
|
||||||
return truncateTitle(firstUserMessage.trim(), DERIVED_TITLE_MAX_LEN);
|
const normalized = firstUserMessage.replace(/\s+/g, " ").trim();
|
||||||
|
return truncateTitle(normalized, DERIVED_TITLE_MAX_LEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.sessionId) {
|
if (entry.sessionId) {
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import {
|
import {
|
||||||
Input,
|
Input,
|
||||||
matchesKey,
|
matchesKey,
|
||||||
type SelectItem,
|
type SelectItem,
|
||||||
SelectList,
|
SelectList,
|
||||||
type SelectListTheme,
|
type SelectListTheme,
|
||||||
getEditorKeybindings,
|
getEditorKeybindings,
|
||||||
} from "@mariozechner/pi-tui";
|
} from "@mariozechner/pi-tui";
|
||||||
import type { Component } from "@mariozechner/pi-tui";
|
import type { Component } from "@mariozechner/pi-tui";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js";
|
import { fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js";
|
||||||
|
|
||||||
export interface FilterableSelectItem extends SelectItem {
|
export interface FilterableSelectItem extends SelectItem {
|
||||||
/** Additional searchable fields beyond label */
|
/** Additional searchable fields beyond label */
|
||||||
searchText?: string;
|
searchText?: string;
|
||||||
/** Pre-computed lowercase search text (label + description + searchText) for filtering */
|
/** Pre-computed lowercase search text (label + description + searchText) for filtering */
|
||||||
searchTextLower?: string;
|
searchTextLower?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FilterableSelectListTheme extends SelectListTheme {
|
export interface FilterableSelectListTheme extends SelectListTheme {
|
||||||
filterLabel: (text: string) => string;
|
filterLabel: (text: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,108 +26,118 @@ export interface FilterableSelectListTheme extends SelectListTheme {
|
|||||||
* User types to filter, arrows/j/k to navigate, Enter to select, Escape to clear/cancel.
|
* User types to filter, arrows/j/k to navigate, Enter to select, Escape to clear/cancel.
|
||||||
*/
|
*/
|
||||||
export class FilterableSelectList implements Component {
|
export class FilterableSelectList implements Component {
|
||||||
private input: Input;
|
private input: Input;
|
||||||
private selectList: SelectList;
|
private selectList: SelectList;
|
||||||
private allItems: FilterableSelectItem[];
|
private allItems: FilterableSelectItem[];
|
||||||
private maxVisible: number;
|
private maxVisible: number;
|
||||||
private theme: FilterableSelectListTheme;
|
private theme: FilterableSelectListTheme;
|
||||||
private filterText = "";
|
private filterText = "";
|
||||||
|
|
||||||
onSelect?: (item: SelectItem) => void;
|
onSelect?: (item: SelectItem) => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
|
|
||||||
constructor(items: FilterableSelectItem[], maxVisible: number, theme: FilterableSelectListTheme) {
|
constructor(items: FilterableSelectItem[], maxVisible: number, theme: FilterableSelectListTheme) {
|
||||||
this.allItems = prepareSearchItems(items);
|
this.allItems = prepareSearchItems(items);
|
||||||
this.maxVisible = maxVisible;
|
this.maxVisible = maxVisible;
|
||||||
this.theme = theme;
|
this.theme = theme;
|
||||||
this.input = new Input();
|
this.input = new Input();
|
||||||
this.selectList = new SelectList(this.allItems, maxVisible, theme);
|
this.selectList = new SelectList(this.allItems, maxVisible, theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyFilter(): void {
|
private applyFilter(): void {
|
||||||
const queryLower = this.filterText.toLowerCase();
|
const queryLower = this.filterText.toLowerCase();
|
||||||
if (!queryLower.trim()) {
|
if (!queryLower.trim()) {
|
||||||
this.selectList = new SelectList(this.allItems, this.maxVisible, this.theme);
|
this.selectList = new SelectList(this.allItems, this.maxVisible, this.theme);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const filtered = fuzzyFilterLower(this.allItems, queryLower);
|
const filtered = fuzzyFilterLower(this.allItems, queryLower);
|
||||||
this.selectList = new SelectList(filtered, this.maxVisible, this.theme);
|
this.selectList = new SelectList(filtered, this.maxVisible, this.theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidate(): void {
|
invalidate(): void {
|
||||||
this.input.invalidate();
|
this.input.invalidate();
|
||||||
this.selectList.invalidate();
|
this.selectList.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
render(width: number): string[] {
|
render(width: number): string[] {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
||||||
// Filter input row
|
// Filter input row
|
||||||
const filterLabel = this.theme.filterLabel("Filter: ");
|
const filterLabel = this.theme.filterLabel("Filter: ");
|
||||||
const inputLines = this.input.render(width - 8);
|
const inputLines = this.input.render(width - 8);
|
||||||
const inputText = inputLines[0] ?? "";
|
const inputText = inputLines[0] ?? "";
|
||||||
lines.push(filterLabel + inputText);
|
lines.push(filterLabel + inputText);
|
||||||
|
|
||||||
// Separator
|
// Separator
|
||||||
lines.push(chalk.dim("─".repeat(width)));
|
lines.push(chalk.dim("─".repeat(width)));
|
||||||
|
|
||||||
// Select list
|
// Select list
|
||||||
const listLines = this.selectList.render(width);
|
const listLines = this.selectList.render(width);
|
||||||
lines.push(...listLines);
|
lines.push(...listLines);
|
||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleInput(keyData: string): void {
|
handleInput(keyData: string): void {
|
||||||
// Navigation: arrows, vim j/k, or ctrl+p/ctrl+n
|
const allowVimNav = !this.filterText.trim();
|
||||||
if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p") || keyData === "k") {
|
|
||||||
this.selectList.handleInput("\x1b[A");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n") || keyData === "j") {
|
// Navigation: arrows, vim j/k, or ctrl+p/ctrl+n
|
||||||
this.selectList.handleInput("\x1b[B");
|
if (
|
||||||
return;
|
matchesKey(keyData, "up") ||
|
||||||
}
|
matchesKey(keyData, "ctrl+p") ||
|
||||||
|
(allowVimNav && keyData === "k")
|
||||||
|
) {
|
||||||
|
this.selectList.handleInput("\x1b[A");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Enter selects
|
if (
|
||||||
if (matchesKey(keyData, "enter")) {
|
matchesKey(keyData, "down") ||
|
||||||
const selected = this.selectList.getSelectedItem();
|
matchesKey(keyData, "ctrl+n") ||
|
||||||
if (selected) {
|
(allowVimNav && keyData === "j")
|
||||||
this.onSelect?.(selected);
|
) {
|
||||||
}
|
this.selectList.handleInput("\x1b[B");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape: clear filter or cancel
|
// Enter selects
|
||||||
const kb = getEditorKeybindings();
|
if (matchesKey(keyData, "enter")) {
|
||||||
if (kb.matches(keyData, "selectCancel")) {
|
const selected = this.selectList.getSelectedItem();
|
||||||
if (this.filterText) {
|
if (selected) {
|
||||||
this.filterText = "";
|
this.onSelect?.(selected);
|
||||||
this.input.setValue("");
|
}
|
||||||
this.applyFilter();
|
return;
|
||||||
} else {
|
}
|
||||||
this.onCancel?.();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// All other input goes to filter
|
// Escape: clear filter or cancel
|
||||||
const prevValue = this.input.getValue();
|
const kb = getEditorKeybindings();
|
||||||
this.input.handleInput(keyData);
|
if (kb.matches(keyData, "selectCancel")) {
|
||||||
const newValue = this.input.getValue();
|
if (this.filterText) {
|
||||||
|
this.filterText = "";
|
||||||
|
this.input.setValue("");
|
||||||
|
this.applyFilter();
|
||||||
|
} else {
|
||||||
|
this.onCancel?.();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (newValue !== prevValue) {
|
// All other input goes to filter
|
||||||
this.filterText = newValue;
|
const prevValue = this.input.getValue();
|
||||||
this.applyFilter();
|
this.input.handleInput(keyData);
|
||||||
}
|
const newValue = this.input.getValue();
|
||||||
}
|
|
||||||
|
|
||||||
getSelectedItem(): SelectItem | null {
|
if (newValue !== prevValue) {
|
||||||
return this.selectList.getSelectedItem();
|
this.filterText = newValue;
|
||||||
}
|
this.applyFilter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getFilterText(): string {
|
getSelectedItem(): SelectItem | null {
|
||||||
return this.filterText;
|
return this.selectList.getSelectedItem();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getFilterText(): string {
|
||||||
|
return this.filterText;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,290 +1,300 @@
|
|||||||
import {
|
import {
|
||||||
type Component,
|
type Component,
|
||||||
fuzzyFilter,
|
fuzzyFilter,
|
||||||
getEditorKeybindings,
|
getEditorKeybindings,
|
||||||
Input,
|
Input,
|
||||||
isKeyRelease,
|
isKeyRelease,
|
||||||
matchesKey,
|
matchesKey,
|
||||||
type SelectItem,
|
type SelectItem,
|
||||||
type SelectListTheme,
|
type SelectListTheme,
|
||||||
truncateToWidth,
|
truncateToWidth,
|
||||||
} from "@mariozechner/pi-tui";
|
} from "@mariozechner/pi-tui";
|
||||||
import { visibleWidth } from "../../terminal/ansi.js";
|
import { visibleWidth } from "../../terminal/ansi.js";
|
||||||
import { findWordBoundaryIndex } from "./fuzzy-filter.js";
|
import { findWordBoundaryIndex } from "./fuzzy-filter.js";
|
||||||
|
|
||||||
export interface SearchableSelectListTheme extends SelectListTheme {
|
export interface SearchableSelectListTheme extends SelectListTheme {
|
||||||
searchPrompt: (text: string) => string;
|
searchPrompt: (text: string) => string;
|
||||||
searchInput: (text: string) => string;
|
searchInput: (text: string) => string;
|
||||||
matchHighlight: (text: string) => string;
|
matchHighlight: (text: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A select list with a search input at the top for fuzzy filtering.
|
* A select list with a search input at the top for fuzzy filtering.
|
||||||
*/
|
*/
|
||||||
export class SearchableSelectList implements Component {
|
export class SearchableSelectList implements Component {
|
||||||
private items: SelectItem[];
|
private items: SelectItem[];
|
||||||
private filteredItems: SelectItem[];
|
private filteredItems: SelectItem[];
|
||||||
private selectedIndex = 0;
|
private selectedIndex = 0;
|
||||||
private maxVisible: number;
|
private maxVisible: number;
|
||||||
private theme: SearchableSelectListTheme;
|
private theme: SearchableSelectListTheme;
|
||||||
private searchInput: Input;
|
private searchInput: Input;
|
||||||
|
|
||||||
onSelect?: (item: SelectItem) => void;
|
onSelect?: (item: SelectItem) => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
onSelectionChange?: (item: SelectItem) => void;
|
onSelectionChange?: (item: SelectItem) => void;
|
||||||
|
|
||||||
constructor(items: SelectItem[], maxVisible: number, theme: SearchableSelectListTheme) {
|
constructor(items: SelectItem[], maxVisible: number, theme: SearchableSelectListTheme) {
|
||||||
this.items = items;
|
this.items = items;
|
||||||
this.filteredItems = items;
|
this.filteredItems = items;
|
||||||
this.maxVisible = maxVisible;
|
this.maxVisible = maxVisible;
|
||||||
this.theme = theme;
|
this.theme = theme;
|
||||||
this.searchInput = new Input();
|
this.searchInput = new Input();
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateFilter() {
|
private updateFilter() {
|
||||||
const query = this.searchInput.getValue().trim();
|
const query = this.searchInput.getValue().trim();
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
this.filteredItems = this.items;
|
this.filteredItems = this.items;
|
||||||
} else {
|
} else {
|
||||||
this.filteredItems = this.smartFilter(query);
|
this.filteredItems = this.smartFilter(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset selection when filter changes
|
// Reset selection when filter changes
|
||||||
this.selectedIndex = 0;
|
this.selectedIndex = 0;
|
||||||
this.notifySelectionChange();
|
this.notifySelectionChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Smart filtering that prioritizes:
|
* Smart filtering that prioritizes:
|
||||||
* 1. Exact substring match in label (highest priority)
|
* 1. Exact substring match in label (highest priority)
|
||||||
* 2. Word-boundary prefix match in label
|
* 2. Word-boundary prefix match in label
|
||||||
* 3. Exact substring match in description
|
* 3. Exact substring match in description
|
||||||
* 4. Fuzzy match (lowest priority)
|
* 4. Fuzzy match (lowest priority)
|
||||||
*/
|
*/
|
||||||
private smartFilter(query: string): SelectItem[] {
|
private smartFilter(query: string): SelectItem[] {
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
type ScoredItem = { item: SelectItem; score: number };
|
type ScoredItem = { item: SelectItem; score: number };
|
||||||
const exactLabel: ScoredItem[] = [];
|
const exactLabel: ScoredItem[] = [];
|
||||||
const wordBoundary: ScoredItem[] = [];
|
const wordBoundary: ScoredItem[] = [];
|
||||||
const descriptionMatches: ScoredItem[] = [];
|
const descriptionMatches: ScoredItem[] = [];
|
||||||
const fuzzyCandidates: SelectItem[] = [];
|
const fuzzyCandidates: SelectItem[] = [];
|
||||||
|
|
||||||
for (const item of this.items) {
|
for (const item of this.items) {
|
||||||
const label = item.label.toLowerCase();
|
const label = item.label.toLowerCase();
|
||||||
const desc = (item.description ?? "").toLowerCase();
|
const desc = (item.description ?? "").toLowerCase();
|
||||||
|
|
||||||
// Tier 1: Exact substring in label (score 0-99)
|
// Tier 1: Exact substring in label (score 0-99)
|
||||||
const labelIndex = label.indexOf(q);
|
const labelIndex = label.indexOf(q);
|
||||||
if (labelIndex !== -1) {
|
if (labelIndex !== -1) {
|
||||||
// Earlier match = better score
|
// Earlier match = better score
|
||||||
exactLabel.push({ item, score: labelIndex });
|
exactLabel.push({ item, score: labelIndex });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Tier 2: Word-boundary prefix in label (score 100-199)
|
// Tier 2: Word-boundary prefix in label (score 100-199)
|
||||||
const wordBoundaryIndex = findWordBoundaryIndex(label, q);
|
const wordBoundaryIndex = findWordBoundaryIndex(label, q);
|
||||||
if (wordBoundaryIndex !== null) {
|
if (wordBoundaryIndex !== null) {
|
||||||
wordBoundary.push({ item, score: wordBoundaryIndex });
|
wordBoundary.push({ item, score: wordBoundaryIndex });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Tier 3: Exact substring in description (score 200-299)
|
// Tier 3: Exact substring in description (score 200-299)
|
||||||
const descIndex = desc.indexOf(q);
|
const descIndex = desc.indexOf(q);
|
||||||
if (descIndex !== -1) {
|
if (descIndex !== -1) {
|
||||||
descriptionMatches.push({ item, score: descIndex });
|
descriptionMatches.push({ item, score: descIndex });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Tier 4: Fuzzy match (score 300+)
|
// Tier 4: Fuzzy match (score 300+)
|
||||||
fuzzyCandidates.push(item);
|
fuzzyCandidates.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
exactLabel.sort(this.compareByScore);
|
exactLabel.sort(this.compareByScore);
|
||||||
wordBoundary.sort(this.compareByScore);
|
wordBoundary.sort(this.compareByScore);
|
||||||
descriptionMatches.sort(this.compareByScore);
|
descriptionMatches.sort(this.compareByScore);
|
||||||
const fuzzyMatches = fuzzyFilter(
|
const fuzzyMatches = fuzzyFilter(
|
||||||
fuzzyCandidates,
|
fuzzyCandidates,
|
||||||
query,
|
query,
|
||||||
(i) => `${i.label} ${i.description ?? ""}`,
|
(i) => `${i.label} ${i.description ?? ""}`,
|
||||||
);
|
);
|
||||||
return [
|
return [
|
||||||
...exactLabel.map((s) => s.item),
|
...exactLabel.map((s) => s.item),
|
||||||
...wordBoundary.map((s) => s.item),
|
...wordBoundary.map((s) => s.item),
|
||||||
...descriptionMatches.map((s) => s.item),
|
...descriptionMatches.map((s) => s.item),
|
||||||
...fuzzyMatches,
|
...fuzzyMatches,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private escapeRegex(str: string): string {
|
private escapeRegex(str: string): string {
|
||||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
private compareByScore = (
|
private compareByScore = (
|
||||||
a: { item: SelectItem; score: number },
|
a: { item: SelectItem; score: number },
|
||||||
b: { item: SelectItem; score: number },
|
b: { item: SelectItem; score: number },
|
||||||
) => {
|
) => {
|
||||||
if (a.score !== b.score) return a.score - b.score;
|
if (a.score !== b.score) return a.score - b.score;
|
||||||
return this.getItemLabel(a.item).localeCompare(this.getItemLabel(b.item));
|
return this.getItemLabel(a.item).localeCompare(this.getItemLabel(b.item));
|
||||||
};
|
};
|
||||||
|
|
||||||
private getItemLabel(item: SelectItem): string {
|
private getItemLabel(item: SelectItem): string {
|
||||||
return item.label || item.value;
|
return item.label || item.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private highlightMatch(text: string, query: string): string {
|
private highlightMatch(text: string, query: string): string {
|
||||||
const tokens = query
|
const tokens = query
|
||||||
.trim()
|
.trim()
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.map((token) => token.toLowerCase())
|
.map((token) => token.toLowerCase())
|
||||||
.filter((token) => token.length > 0);
|
.filter((token) => token.length > 0);
|
||||||
if (tokens.length === 0) return text;
|
if (tokens.length === 0) return text;
|
||||||
|
|
||||||
const uniqueTokens = Array.from(new Set(tokens)).sort((a, b) => b.length - a.length);
|
const uniqueTokens = Array.from(new Set(tokens)).sort((a, b) => b.length - a.length);
|
||||||
let result = text;
|
let result = text;
|
||||||
for (const token of uniqueTokens) {
|
for (const token of uniqueTokens) {
|
||||||
const regex = new RegExp(this.escapeRegex(token), "gi");
|
const regex = new RegExp(this.escapeRegex(token), "gi");
|
||||||
result = result.replace(regex, (match) => this.theme.matchHighlight(match));
|
result = result.replace(regex, (match) => this.theme.matchHighlight(match));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedIndex(index: number) {
|
setSelectedIndex(index: number) {
|
||||||
this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));
|
this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidate() {
|
invalidate() {
|
||||||
this.searchInput.invalidate();
|
this.searchInput.invalidate();
|
||||||
}
|
}
|
||||||
|
|
||||||
render(width: number): string[] {
|
render(width: number): string[] {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
||||||
// Search input line
|
// Search input line
|
||||||
const promptText = "search: ";
|
const promptText = "search: ";
|
||||||
const prompt = this.theme.searchPrompt(promptText);
|
const prompt = this.theme.searchPrompt(promptText);
|
||||||
const inputWidth = Math.max(1, width - visibleWidth(prompt));
|
const inputWidth = Math.max(1, width - visibleWidth(prompt));
|
||||||
const inputLines = this.searchInput.render(inputWidth);
|
const inputLines = this.searchInput.render(inputWidth);
|
||||||
const inputText = inputLines[0] ?? "";
|
const inputText = inputLines[0] ?? "";
|
||||||
lines.push(`${prompt}${this.theme.searchInput(inputText)}`);
|
lines.push(`${prompt}${this.theme.searchInput(inputText)}`);
|
||||||
lines.push(""); // Spacer
|
lines.push(""); // Spacer
|
||||||
|
|
||||||
const query = this.searchInput.getValue().trim();
|
const query = this.searchInput.getValue().trim();
|
||||||
|
|
||||||
// If no items match filter, show message
|
// If no items match filter, show message
|
||||||
if (this.filteredItems.length === 0) {
|
if (this.filteredItems.length === 0) {
|
||||||
lines.push(this.theme.noMatch(" No matches"));
|
lines.push(this.theme.noMatch(" No matches"));
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate visible range with scrolling
|
// Calculate visible range with scrolling
|
||||||
const startIndex = Math.max(
|
const startIndex = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(
|
Math.min(
|
||||||
this.selectedIndex - Math.floor(this.maxVisible / 2),
|
this.selectedIndex - Math.floor(this.maxVisible / 2),
|
||||||
this.filteredItems.length - this.maxVisible,
|
this.filteredItems.length - this.maxVisible,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
|
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
|
||||||
|
|
||||||
// Render visible items
|
// Render visible items
|
||||||
for (let i = startIndex; i < endIndex; i++) {
|
for (let i = startIndex; i < endIndex; i++) {
|
||||||
const item = this.filteredItems[i];
|
const item = this.filteredItems[i];
|
||||||
if (!item) continue;
|
if (!item) continue;
|
||||||
const isSelected = i === this.selectedIndex;
|
const isSelected = i === this.selectedIndex;
|
||||||
lines.push(this.renderItemLine(item, isSelected, width, query));
|
lines.push(this.renderItemLine(item, isSelected, width, query));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show scroll indicator if needed
|
// Show scroll indicator if needed
|
||||||
if (this.filteredItems.length > this.maxVisible) {
|
if (this.filteredItems.length > this.maxVisible) {
|
||||||
const scrollInfo = `${this.selectedIndex + 1}/${this.filteredItems.length}`;
|
const scrollInfo = `${this.selectedIndex + 1}/${this.filteredItems.length}`;
|
||||||
lines.push(this.theme.scrollInfo(` ${scrollInfo}`));
|
lines.push(this.theme.scrollInfo(` ${scrollInfo}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderItemLine(
|
private renderItemLine(
|
||||||
item: SelectItem,
|
item: SelectItem,
|
||||||
isSelected: boolean,
|
isSelected: boolean,
|
||||||
width: number,
|
width: number,
|
||||||
query: string,
|
query: string,
|
||||||
): string {
|
): string {
|
||||||
const prefix = isSelected ? "→ " : " ";
|
const prefix = isSelected ? "→ " : " ";
|
||||||
const prefixWidth = prefix.length;
|
const prefixWidth = prefix.length;
|
||||||
const displayValue = this.getItemLabel(item);
|
const displayValue = this.getItemLabel(item);
|
||||||
|
|
||||||
if (item.description && width > 40) {
|
if (item.description && width > 40) {
|
||||||
const maxValueWidth = Math.min(30, width - prefixWidth - 4);
|
const maxValueWidth = Math.min(30, width - prefixWidth - 4);
|
||||||
const truncatedValue = truncateToWidth(displayValue, maxValueWidth, "");
|
const truncatedValue = truncateToWidth(displayValue, maxValueWidth, "");
|
||||||
const valueText = this.highlightMatch(truncatedValue, query);
|
const valueText = this.highlightMatch(truncatedValue, query);
|
||||||
const spacing = " ".repeat(Math.max(1, 32 - visibleWidth(valueText)));
|
const spacing = " ".repeat(Math.max(1, 32 - visibleWidth(valueText)));
|
||||||
const descriptionStart = prefixWidth + visibleWidth(valueText) + spacing.length;
|
const descriptionStart = prefixWidth + visibleWidth(valueText) + spacing.length;
|
||||||
const remainingWidth = width - descriptionStart - 2;
|
const remainingWidth = width - descriptionStart - 2;
|
||||||
if (remainingWidth > 10) {
|
if (remainingWidth > 10) {
|
||||||
const truncatedDesc = truncateToWidth(item.description, remainingWidth, "");
|
const truncatedDesc = truncateToWidth(item.description, remainingWidth, "");
|
||||||
const descText = isSelected
|
const descText = isSelected
|
||||||
? this.highlightMatch(truncatedDesc, query)
|
? this.highlightMatch(truncatedDesc, query)
|
||||||
: this.highlightMatch(this.theme.description(truncatedDesc), query);
|
: this.highlightMatch(this.theme.description(truncatedDesc), query);
|
||||||
const line = `${prefix}${valueText}${spacing}${descText}`;
|
const line = `${prefix}${valueText}${spacing}${descText}`;
|
||||||
return isSelected ? this.theme.selectedText(line) : line;
|
return isSelected ? this.theme.selectedText(line) : line;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxWidth = width - prefixWidth - 2;
|
const maxWidth = width - prefixWidth - 2;
|
||||||
const truncatedValue = truncateToWidth(displayValue, maxWidth, "");
|
const truncatedValue = truncateToWidth(displayValue, maxWidth, "");
|
||||||
const valueText = this.highlightMatch(truncatedValue, query);
|
const valueText = this.highlightMatch(truncatedValue, query);
|
||||||
const line = `${prefix}${valueText}`;
|
const line = `${prefix}${valueText}`;
|
||||||
return isSelected ? this.theme.selectedText(line) : line;
|
return isSelected ? this.theme.selectedText(line) : line;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleInput(keyData: string): void {
|
handleInput(keyData: string): void {
|
||||||
if (isKeyRelease(keyData)) return;
|
if (isKeyRelease(keyData)) return;
|
||||||
|
|
||||||
// Navigation keys
|
const allowVimNav = !this.searchInput.getValue().trim();
|
||||||
if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p") || keyData === "k") {
|
|
||||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
||||||
this.notifySelectionChange();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n") || keyData === "j") {
|
// Navigation keys
|
||||||
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
|
if (
|
||||||
this.notifySelectionChange();
|
matchesKey(keyData, "up") ||
|
||||||
return;
|
matchesKey(keyData, "ctrl+p") ||
|
||||||
}
|
(allowVimNav && keyData === "k")
|
||||||
|
) {
|
||||||
|
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||||
|
this.notifySelectionChange();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (matchesKey(keyData, "enter")) {
|
if (
|
||||||
const item = this.filteredItems[this.selectedIndex];
|
matchesKey(keyData, "down") ||
|
||||||
if (item && this.onSelect) {
|
matchesKey(keyData, "ctrl+n") ||
|
||||||
this.onSelect(item);
|
(allowVimNav && keyData === "j")
|
||||||
}
|
) {
|
||||||
return;
|
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
|
||||||
}
|
this.notifySelectionChange();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const kb = getEditorKeybindings();
|
if (matchesKey(keyData, "enter")) {
|
||||||
if (kb.matches(keyData, "selectCancel")) {
|
const item = this.filteredItems[this.selectedIndex];
|
||||||
if (this.onCancel) {
|
if (item && this.onSelect) {
|
||||||
this.onCancel();
|
this.onSelect(item);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass other keys to search input
|
const kb = getEditorKeybindings();
|
||||||
const prevValue = this.searchInput.getValue();
|
if (kb.matches(keyData, "selectCancel")) {
|
||||||
this.searchInput.handleInput(keyData);
|
if (this.onCancel) {
|
||||||
const newValue = this.searchInput.getValue();
|
this.onCancel();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (prevValue !== newValue) {
|
// Pass other keys to search input
|
||||||
this.updateFilter();
|
const prevValue = this.searchInput.getValue();
|
||||||
}
|
this.searchInput.handleInput(keyData);
|
||||||
}
|
const newValue = this.searchInput.getValue();
|
||||||
|
|
||||||
private notifySelectionChange() {
|
if (prevValue !== newValue) {
|
||||||
const item = this.filteredItems[this.selectedIndex];
|
this.updateFilter();
|
||||||
if (item && this.onSelectionChange) {
|
}
|
||||||
this.onSelectionChange(item);
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getSelectedItem(): SelectItem | null {
|
private notifySelectionChange() {
|
||||||
return this.filteredItems[this.selectedIndex] ?? null;
|
const item = this.filteredItems[this.selectedIndex];
|
||||||
}
|
if (item && this.onSelectionChange) {
|
||||||
|
this.onSelectionChange(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelectedItem(): SelectItem | null {
|
||||||
|
return this.filteredItems[this.selectedIndex] ?? null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,462 +1,463 @@
|
|||||||
import type { Component, TUI } from "@mariozechner/pi-tui";
|
import type { Component, TUI } from "@mariozechner/pi-tui";
|
||||||
import {
|
import {
|
||||||
formatThinkingLevels,
|
formatThinkingLevels,
|
||||||
normalizeUsageDisplay,
|
normalizeUsageDisplay,
|
||||||
resolveResponseUsageMode,
|
resolveResponseUsageMode,
|
||||||
} from "../auto-reply/thinking.js";
|
} from "../auto-reply/thinking.js";
|
||||||
import { normalizeAgentId } from "../routing/session-key.js";
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
import { formatRelativeTime } from "../utils/time-format.js";
|
import { formatRelativeTime } from "../utils/time-format.js";
|
||||||
import { helpText, parseCommand } from "./commands.js";
|
import { helpText, parseCommand } from "./commands.js";
|
||||||
import type { ChatLog } from "./components/chat-log.js";
|
import type { ChatLog } from "./components/chat-log.js";
|
||||||
import {
|
import {
|
||||||
createFilterableSelectList,
|
createFilterableSelectList,
|
||||||
createSearchableSelectList,
|
createSearchableSelectList,
|
||||||
createSettingsList,
|
createSettingsList,
|
||||||
} from "./components/selectors.js";
|
} from "./components/selectors.js";
|
||||||
import type { GatewayChatClient } from "./gateway-chat.js";
|
import type { GatewayChatClient } from "./gateway-chat.js";
|
||||||
import { formatStatusSummary } from "./tui-status-summary.js";
|
import { formatStatusSummary } from "./tui-status-summary.js";
|
||||||
import type {
|
import type {
|
||||||
AgentSummary,
|
AgentSummary,
|
||||||
GatewayStatusSummary,
|
GatewayStatusSummary,
|
||||||
TuiOptions,
|
TuiOptions,
|
||||||
TuiStateAccess,
|
TuiStateAccess,
|
||||||
} from "./tui-types.js";
|
} from "./tui-types.js";
|
||||||
|
|
||||||
type CommandHandlerContext = {
|
type CommandHandlerContext = {
|
||||||
client: GatewayChatClient;
|
client: GatewayChatClient;
|
||||||
chatLog: ChatLog;
|
chatLog: ChatLog;
|
||||||
tui: TUI;
|
tui: TUI;
|
||||||
opts: TuiOptions;
|
opts: TuiOptions;
|
||||||
state: TuiStateAccess;
|
state: TuiStateAccess;
|
||||||
deliverDefault: boolean;
|
deliverDefault: boolean;
|
||||||
openOverlay: (component: Component) => void;
|
openOverlay: (component: Component) => void;
|
||||||
closeOverlay: () => void;
|
closeOverlay: () => void;
|
||||||
refreshSessionInfo: () => Promise<void>;
|
refreshSessionInfo: () => Promise<void>;
|
||||||
loadHistory: () => Promise<void>;
|
loadHistory: () => Promise<void>;
|
||||||
setSession: (key: string) => Promise<void>;
|
setSession: (key: string) => Promise<void>;
|
||||||
refreshAgents: () => Promise<void>;
|
refreshAgents: () => Promise<void>;
|
||||||
abortActive: () => Promise<void>;
|
abortActive: () => Promise<void>;
|
||||||
setActivityStatus: (text: string) => void;
|
setActivityStatus: (text: string) => void;
|
||||||
formatSessionKey: (key: string) => string;
|
formatSessionKey: (key: string) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createCommandHandlers(context: CommandHandlerContext) {
|
export function createCommandHandlers(context: CommandHandlerContext) {
|
||||||
const {
|
const {
|
||||||
client,
|
client,
|
||||||
chatLog,
|
chatLog,
|
||||||
tui,
|
tui,
|
||||||
opts,
|
opts,
|
||||||
state,
|
state,
|
||||||
deliverDefault,
|
deliverDefault,
|
||||||
openOverlay,
|
openOverlay,
|
||||||
closeOverlay,
|
closeOverlay,
|
||||||
refreshSessionInfo,
|
refreshSessionInfo,
|
||||||
loadHistory,
|
loadHistory,
|
||||||
setSession,
|
setSession,
|
||||||
refreshAgents,
|
refreshAgents,
|
||||||
abortActive,
|
abortActive,
|
||||||
setActivityStatus,
|
setActivityStatus,
|
||||||
formatSessionKey,
|
formatSessionKey,
|
||||||
} = context;
|
} = context;
|
||||||
|
|
||||||
const setAgent = async (id: string) => {
|
const setAgent = async (id: string) => {
|
||||||
state.currentAgentId = normalizeAgentId(id);
|
state.currentAgentId = normalizeAgentId(id);
|
||||||
await setSession("");
|
await setSession("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const openModelSelector = async () => {
|
const openModelSelector = async () => {
|
||||||
try {
|
try {
|
||||||
const models = await client.listModels();
|
const models = await client.listModels();
|
||||||
if (models.length === 0) {
|
if (models.length === 0) {
|
||||||
chatLog.addSystem("no models available");
|
chatLog.addSystem("no models available");
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const items = models.map((model) => ({
|
const items = models.map((model) => ({
|
||||||
value: `${model.provider}/${model.id}`,
|
value: `${model.provider}/${model.id}`,
|
||||||
label: `${model.provider}/${model.id}`,
|
label: `${model.provider}/${model.id}`,
|
||||||
description: model.name && model.name !== model.id ? model.name : "",
|
description: model.name && model.name !== model.id ? model.name : "",
|
||||||
}));
|
}));
|
||||||
const selector = createSearchableSelectList(items, 9);
|
const selector = createSearchableSelectList(items, 9);
|
||||||
selector.onSelect = (item) => {
|
selector.onSelect = (item) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await client.patchSession({
|
await client.patchSession({
|
||||||
key: state.currentSessionKey,
|
key: state.currentSessionKey,
|
||||||
model: item.value,
|
model: item.value,
|
||||||
});
|
});
|
||||||
chatLog.addSystem(`model set to ${item.value}`);
|
chatLog.addSystem(`model set to ${item.value}`);
|
||||||
await refreshSessionInfo();
|
await refreshSessionInfo();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
chatLog.addSystem(`model set failed: ${String(err)}`);
|
chatLog.addSystem(`model set failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
closeOverlay();
|
closeOverlay();
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
})();
|
})();
|
||||||
};
|
};
|
||||||
selector.onCancel = () => {
|
selector.onCancel = () => {
|
||||||
closeOverlay();
|
closeOverlay();
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
};
|
};
|
||||||
openOverlay(selector);
|
openOverlay(selector);
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
chatLog.addSystem(`model list failed: ${String(err)}`);
|
chatLog.addSystem(`model list failed: ${String(err)}`);
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openAgentSelector = async () => {
|
const openAgentSelector = async () => {
|
||||||
await refreshAgents();
|
await refreshAgents();
|
||||||
if (state.agents.length === 0) {
|
if (state.agents.length === 0) {
|
||||||
chatLog.addSystem("no agents found");
|
chatLog.addSystem("no agents found");
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const items = state.agents.map((agent: AgentSummary) => ({
|
const items = state.agents.map((agent: AgentSummary) => ({
|
||||||
value: agent.id,
|
value: agent.id,
|
||||||
label: agent.name ? `${agent.id} (${agent.name})` : agent.id,
|
label: agent.name ? `${agent.id} (${agent.name})` : agent.id,
|
||||||
description: agent.id === state.agentDefaultId ? "default" : "",
|
description: agent.id === state.agentDefaultId ? "default" : "",
|
||||||
}));
|
}));
|
||||||
const selector = createSearchableSelectList(items, 9);
|
const selector = createSearchableSelectList(items, 9);
|
||||||
selector.onSelect = (item) => {
|
selector.onSelect = (item) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
closeOverlay();
|
closeOverlay();
|
||||||
await setAgent(item.value);
|
await setAgent(item.value);
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
})();
|
})();
|
||||||
};
|
};
|
||||||
selector.onCancel = () => {
|
selector.onCancel = () => {
|
||||||
closeOverlay();
|
closeOverlay();
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
};
|
};
|
||||||
openOverlay(selector);
|
openOverlay(selector);
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openSessionSelector = async () => {
|
const openSessionSelector = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await client.listSessions({
|
const result = await client.listSessions({
|
||||||
includeGlobal: false,
|
includeGlobal: false,
|
||||||
includeUnknown: false,
|
includeUnknown: false,
|
||||||
includeDerivedTitles: true,
|
includeDerivedTitles: true,
|
||||||
includeLastMessage: true,
|
includeLastMessage: true,
|
||||||
agentId: state.currentAgentId,
|
agentId: state.currentAgentId,
|
||||||
});
|
});
|
||||||
const items = result.sessions.map((session) => {
|
const items = result.sessions.map((session) => {
|
||||||
const title = session.derivedTitle ?? session.displayName;
|
const title = session.derivedTitle ?? session.displayName;
|
||||||
const formattedKey = formatSessionKey(session.key);
|
const formattedKey = formatSessionKey(session.key);
|
||||||
// Avoid redundant "title (key)" when title matches key
|
// Avoid redundant "title (key)" when title matches key
|
||||||
const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey;
|
const label = title && title !== formattedKey ? `${title} (${formattedKey})` : formattedKey;
|
||||||
// Build description: time + message preview
|
// Build description: time + message preview
|
||||||
const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : "";
|
const timePart = session.updatedAt ? formatRelativeTime(session.updatedAt) : "";
|
||||||
const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim();
|
const preview = session.lastMessagePreview?.replace(/\s+/g, " ").trim();
|
||||||
const description = preview ? `${timePart} · ${preview}` : timePart;
|
const description =
|
||||||
return {
|
timePart && preview ? `${timePart} · ${preview}` : (preview ?? timePart);
|
||||||
value: session.key,
|
return {
|
||||||
label,
|
value: session.key,
|
||||||
description,
|
label,
|
||||||
searchText: [
|
description,
|
||||||
session.displayName,
|
searchText: [
|
||||||
session.label,
|
session.displayName,
|
||||||
session.subject,
|
session.label,
|
||||||
session.sessionId,
|
session.subject,
|
||||||
session.key,
|
session.sessionId,
|
||||||
session.lastMessagePreview,
|
session.key,
|
||||||
]
|
session.lastMessagePreview,
|
||||||
.filter(Boolean)
|
]
|
||||||
.join(" "),
|
.filter(Boolean)
|
||||||
};
|
.join(" "),
|
||||||
});
|
};
|
||||||
const selector = createFilterableSelectList(items, 9);
|
});
|
||||||
selector.onSelect = (item) => {
|
const selector = createFilterableSelectList(items, 9);
|
||||||
void (async () => {
|
selector.onSelect = (item) => {
|
||||||
closeOverlay();
|
void (async () => {
|
||||||
await setSession(item.value);
|
closeOverlay();
|
||||||
tui.requestRender();
|
await setSession(item.value);
|
||||||
})();
|
tui.requestRender();
|
||||||
};
|
})();
|
||||||
selector.onCancel = () => {
|
};
|
||||||
closeOverlay();
|
selector.onCancel = () => {
|
||||||
tui.requestRender();
|
closeOverlay();
|
||||||
};
|
tui.requestRender();
|
||||||
openOverlay(selector);
|
};
|
||||||
tui.requestRender();
|
openOverlay(selector);
|
||||||
} catch (err) {
|
tui.requestRender();
|
||||||
chatLog.addSystem(`sessions list failed: ${String(err)}`);
|
} catch (err) {
|
||||||
tui.requestRender();
|
chatLog.addSystem(`sessions list failed: ${String(err)}`);
|
||||||
}
|
tui.requestRender();
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openSettings = () => {
|
const openSettings = () => {
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
id: "tools",
|
id: "tools",
|
||||||
label: "Tool output",
|
label: "Tool output",
|
||||||
currentValue: state.toolsExpanded ? "expanded" : "collapsed",
|
currentValue: state.toolsExpanded ? "expanded" : "collapsed",
|
||||||
values: ["collapsed", "expanded"],
|
values: ["collapsed", "expanded"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "thinking",
|
id: "thinking",
|
||||||
label: "Show thinking",
|
label: "Show thinking",
|
||||||
currentValue: state.showThinking ? "on" : "off",
|
currentValue: state.showThinking ? "on" : "off",
|
||||||
values: ["off", "on"],
|
values: ["off", "on"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const settings = createSettingsList(
|
const settings = createSettingsList(
|
||||||
items,
|
items,
|
||||||
(id, value) => {
|
(id, value) => {
|
||||||
if (id === "tools") {
|
if (id === "tools") {
|
||||||
state.toolsExpanded = value === "expanded";
|
state.toolsExpanded = value === "expanded";
|
||||||
chatLog.setToolsExpanded(state.toolsExpanded);
|
chatLog.setToolsExpanded(state.toolsExpanded);
|
||||||
}
|
}
|
||||||
if (id === "thinking") {
|
if (id === "thinking") {
|
||||||
state.showThinking = value === "on";
|
state.showThinking = value === "on";
|
||||||
void loadHistory();
|
void loadHistory();
|
||||||
}
|
}
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
closeOverlay();
|
closeOverlay();
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
openOverlay(settings);
|
openOverlay(settings);
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCommand = async (raw: string) => {
|
const handleCommand = async (raw: string) => {
|
||||||
const { name, args } = parseCommand(raw);
|
const { name, args } = parseCommand(raw);
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case "help":
|
case "help":
|
||||||
chatLog.addSystem(
|
chatLog.addSystem(
|
||||||
helpText({
|
helpText({
|
||||||
provider: state.sessionInfo.modelProvider,
|
provider: state.sessionInfo.modelProvider,
|
||||||
model: state.sessionInfo.model,
|
model: state.sessionInfo.model,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "status":
|
case "status":
|
||||||
try {
|
try {
|
||||||
const status = await client.getStatus();
|
const status = await client.getStatus();
|
||||||
if (typeof status === "string") {
|
if (typeof status === "string") {
|
||||||
chatLog.addSystem(status);
|
chatLog.addSystem(status);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (status && typeof status === "object") {
|
if (status && typeof status === "object") {
|
||||||
const lines = formatStatusSummary(status as GatewayStatusSummary);
|
const lines = formatStatusSummary(status as GatewayStatusSummary);
|
||||||
for (const line of lines) chatLog.addSystem(line);
|
for (const line of lines) chatLog.addSystem(line);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
chatLog.addSystem("status: unknown response");
|
chatLog.addSystem("status: unknown response");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
chatLog.addSystem(`status failed: ${String(err)}`);
|
chatLog.addSystem(`status failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "agent":
|
case "agent":
|
||||||
if (!args) {
|
if (!args) {
|
||||||
await openAgentSelector();
|
await openAgentSelector();
|
||||||
} else {
|
} else {
|
||||||
await setAgent(args);
|
await setAgent(args);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "agents":
|
case "agents":
|
||||||
await openAgentSelector();
|
await openAgentSelector();
|
||||||
break;
|
break;
|
||||||
case "session":
|
case "session":
|
||||||
if (!args) {
|
if (!args) {
|
||||||
await openSessionSelector();
|
await openSessionSelector();
|
||||||
} else {
|
} else {
|
||||||
await setSession(args);
|
await setSession(args);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "sessions":
|
case "sessions":
|
||||||
await openSessionSelector();
|
await openSessionSelector();
|
||||||
break;
|
break;
|
||||||
case "model":
|
case "model":
|
||||||
if (!args) {
|
if (!args) {
|
||||||
await openModelSelector();
|
await openModelSelector();
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await client.patchSession({
|
await client.patchSession({
|
||||||
key: state.currentSessionKey,
|
key: state.currentSessionKey,
|
||||||
model: args,
|
model: args,
|
||||||
});
|
});
|
||||||
chatLog.addSystem(`model set to ${args}`);
|
chatLog.addSystem(`model set to ${args}`);
|
||||||
await refreshSessionInfo();
|
await refreshSessionInfo();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
chatLog.addSystem(`model set failed: ${String(err)}`);
|
chatLog.addSystem(`model set failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "models":
|
case "models":
|
||||||
await openModelSelector();
|
await openModelSelector();
|
||||||
break;
|
break;
|
||||||
case "think":
|
case "think":
|
||||||
if (!args) {
|
if (!args) {
|
||||||
const levels = formatThinkingLevels(
|
const levels = formatThinkingLevels(
|
||||||
state.sessionInfo.modelProvider,
|
state.sessionInfo.modelProvider,
|
||||||
state.sessionInfo.model,
|
state.sessionInfo.model,
|
||||||
"|",
|
"|",
|
||||||
);
|
);
|
||||||
chatLog.addSystem(`usage: /think <${levels}>`);
|
chatLog.addSystem(`usage: /think <${levels}>`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await client.patchSession({
|
await client.patchSession({
|
||||||
key: state.currentSessionKey,
|
key: state.currentSessionKey,
|
||||||
thinkingLevel: args,
|
thinkingLevel: args,
|
||||||
});
|
});
|
||||||
chatLog.addSystem(`thinking set to ${args}`);
|
chatLog.addSystem(`thinking set to ${args}`);
|
||||||
await refreshSessionInfo();
|
await refreshSessionInfo();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
chatLog.addSystem(`think failed: ${String(err)}`);
|
chatLog.addSystem(`think failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "verbose":
|
case "verbose":
|
||||||
if (!args) {
|
if (!args) {
|
||||||
chatLog.addSystem("usage: /verbose <on|off>");
|
chatLog.addSystem("usage: /verbose <on|off>");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await client.patchSession({
|
await client.patchSession({
|
||||||
key: state.currentSessionKey,
|
key: state.currentSessionKey,
|
||||||
verboseLevel: args,
|
verboseLevel: args,
|
||||||
});
|
});
|
||||||
chatLog.addSystem(`verbose set to ${args}`);
|
chatLog.addSystem(`verbose set to ${args}`);
|
||||||
await refreshSessionInfo();
|
await refreshSessionInfo();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
chatLog.addSystem(`verbose failed: ${String(err)}`);
|
chatLog.addSystem(`verbose failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "reasoning":
|
case "reasoning":
|
||||||
if (!args) {
|
if (!args) {
|
||||||
chatLog.addSystem("usage: /reasoning <on|off>");
|
chatLog.addSystem("usage: /reasoning <on|off>");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await client.patchSession({
|
await client.patchSession({
|
||||||
key: state.currentSessionKey,
|
key: state.currentSessionKey,
|
||||||
reasoningLevel: args,
|
reasoningLevel: args,
|
||||||
});
|
});
|
||||||
chatLog.addSystem(`reasoning set to ${args}`);
|
chatLog.addSystem(`reasoning set to ${args}`);
|
||||||
await refreshSessionInfo();
|
await refreshSessionInfo();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
chatLog.addSystem(`reasoning failed: ${String(err)}`);
|
chatLog.addSystem(`reasoning failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "usage": {
|
case "usage": {
|
||||||
const normalized = args ? normalizeUsageDisplay(args) : undefined;
|
const normalized = args ? normalizeUsageDisplay(args) : undefined;
|
||||||
if (args && !normalized) {
|
if (args && !normalized) {
|
||||||
chatLog.addSystem("usage: /usage <off|tokens|full>");
|
chatLog.addSystem("usage: /usage <off|tokens|full>");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const currentRaw = state.sessionInfo.responseUsage;
|
const currentRaw = state.sessionInfo.responseUsage;
|
||||||
const current = resolveResponseUsageMode(currentRaw);
|
const current = resolveResponseUsageMode(currentRaw);
|
||||||
const next =
|
const next =
|
||||||
normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off");
|
normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off");
|
||||||
try {
|
try {
|
||||||
await client.patchSession({
|
await client.patchSession({
|
||||||
key: state.currentSessionKey,
|
key: state.currentSessionKey,
|
||||||
responseUsage: next === "off" ? null : next,
|
responseUsage: next === "off" ? null : next,
|
||||||
});
|
});
|
||||||
chatLog.addSystem(`usage footer: ${next}`);
|
chatLog.addSystem(`usage footer: ${next}`);
|
||||||
await refreshSessionInfo();
|
await refreshSessionInfo();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
chatLog.addSystem(`usage failed: ${String(err)}`);
|
chatLog.addSystem(`usage failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "elevated":
|
case "elevated":
|
||||||
if (!args) {
|
if (!args) {
|
||||||
chatLog.addSystem("usage: /elevated <on|off>");
|
chatLog.addSystem("usage: /elevated <on|off>");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await client.patchSession({
|
await client.patchSession({
|
||||||
key: state.currentSessionKey,
|
key: state.currentSessionKey,
|
||||||
elevatedLevel: args,
|
elevatedLevel: args,
|
||||||
});
|
});
|
||||||
chatLog.addSystem(`elevated set to ${args}`);
|
chatLog.addSystem(`elevated set to ${args}`);
|
||||||
await refreshSessionInfo();
|
await refreshSessionInfo();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
chatLog.addSystem(`elevated failed: ${String(err)}`);
|
chatLog.addSystem(`elevated failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "activation":
|
case "activation":
|
||||||
if (!args) {
|
if (!args) {
|
||||||
chatLog.addSystem("usage: /activation <mention|always>");
|
chatLog.addSystem("usage: /activation <mention|always>");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await client.patchSession({
|
await client.patchSession({
|
||||||
key: state.currentSessionKey,
|
key: state.currentSessionKey,
|
||||||
groupActivation: args === "always" ? "always" : "mention",
|
groupActivation: args === "always" ? "always" : "mention",
|
||||||
});
|
});
|
||||||
chatLog.addSystem(`activation set to ${args}`);
|
chatLog.addSystem(`activation set to ${args}`);
|
||||||
await refreshSessionInfo();
|
await refreshSessionInfo();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
chatLog.addSystem(`activation failed: ${String(err)}`);
|
chatLog.addSystem(`activation failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "new":
|
case "new":
|
||||||
case "reset":
|
case "reset":
|
||||||
try {
|
try {
|
||||||
await client.resetSession(state.currentSessionKey);
|
await client.resetSession(state.currentSessionKey);
|
||||||
chatLog.addSystem(`session ${state.currentSessionKey} reset`);
|
chatLog.addSystem(`session ${state.currentSessionKey} reset`);
|
||||||
await loadHistory();
|
await loadHistory();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
chatLog.addSystem(`reset failed: ${String(err)}`);
|
chatLog.addSystem(`reset failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "abort":
|
case "abort":
|
||||||
await abortActive();
|
await abortActive();
|
||||||
break;
|
break;
|
||||||
case "settings":
|
case "settings":
|
||||||
openSettings();
|
openSettings();
|
||||||
break;
|
break;
|
||||||
case "exit":
|
case "exit":
|
||||||
case "quit":
|
case "quit":
|
||||||
client.stop();
|
client.stop();
|
||||||
tui.stop();
|
tui.stop();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
chatLog.addSystem(`unknown command: /${name}`);
|
chatLog.addSystem(`unknown command: /${name}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendMessage = async (text: string) => {
|
const sendMessage = async (text: string) => {
|
||||||
try {
|
try {
|
||||||
chatLog.addUser(text);
|
chatLog.addUser(text);
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
setActivityStatus("sending");
|
setActivityStatus("sending");
|
||||||
const { runId } = await client.sendChat({
|
const { runId } = await client.sendChat({
|
||||||
sessionKey: state.currentSessionKey,
|
sessionKey: state.currentSessionKey,
|
||||||
message: text,
|
message: text,
|
||||||
thinking: opts.thinking,
|
thinking: opts.thinking,
|
||||||
deliver: deliverDefault,
|
deliver: deliverDefault,
|
||||||
timeoutMs: opts.timeoutMs,
|
timeoutMs: opts.timeoutMs,
|
||||||
});
|
});
|
||||||
state.activeChatRunId = runId;
|
state.activeChatRunId = runId;
|
||||||
setActivityStatus("waiting");
|
setActivityStatus("waiting");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
chatLog.addSystem(`send failed: ${String(err)}`);
|
chatLog.addSystem(`send failed: ${String(err)}`);
|
||||||
setActivityStatus("error");
|
setActivityStatus("error");
|
||||||
}
|
}
|
||||||
tui.requestRender();
|
tui.requestRender();
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleCommand,
|
handleCommand,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
openModelSelector,
|
openModelSelector,
|
||||||
openAgentSelector,
|
openAgentSelector,
|
||||||
openSessionSelector,
|
openSessionSelector,
|
||||||
openSettings,
|
openSettings,
|
||||||
setAgent,
|
setAgent,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user