@mariozechner/pi-coding-agent 0.12.11 → 0.12.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/fuzzy.js ADDED
@@ -0,0 +1,64 @@
1
+ // Fuzzy search. Matches if all query characters appear in order (not necessarily consecutive).
2
+ // Lower score = better match.
3
+ export function fuzzyMatch(query, text) {
4
+ const queryLower = query.toLowerCase();
5
+ const textLower = text.toLowerCase();
6
+ if (queryLower.length === 0) {
7
+ return { matches: true, score: 0 };
8
+ }
9
+ if (queryLower.length > textLower.length) {
10
+ return { matches: false, score: 0 };
11
+ }
12
+ let queryIndex = 0;
13
+ let score = 0;
14
+ let lastMatchIndex = -1;
15
+ let consecutiveMatches = 0;
16
+ for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
17
+ if (textLower[i] === queryLower[queryIndex]) {
18
+ const isWordBoundary = i === 0 || /[\s\-_./]/.test(textLower[i - 1]);
19
+ // Reward consecutive character matches (e.g., typing "foo" matches "foobar" better than "f_o_o")
20
+ if (lastMatchIndex === i - 1) {
21
+ consecutiveMatches++;
22
+ score -= consecutiveMatches * 5;
23
+ }
24
+ else {
25
+ consecutiveMatches = 0;
26
+ // Penalize gaps between matched characters
27
+ if (lastMatchIndex >= 0) {
28
+ score += (i - lastMatchIndex - 1) * 2;
29
+ }
30
+ }
31
+ // Reward matches at word boundaries (start of words are more likely intentional targets)
32
+ if (isWordBoundary) {
33
+ score -= 10;
34
+ }
35
+ // Slight penalty for matches later in the string (prefer earlier matches)
36
+ score += i * 0.1;
37
+ lastMatchIndex = i;
38
+ queryIndex++;
39
+ }
40
+ }
41
+ // Not all query characters were found in order
42
+ if (queryIndex < queryLower.length) {
43
+ return { matches: false, score: 0 };
44
+ }
45
+ return { matches: true, score };
46
+ }
47
+ // Filter and sort items by fuzzy match quality (best matches first)
48
+ export function fuzzyFilter(items, query, getText) {
49
+ if (!query.trim()) {
50
+ return items;
51
+ }
52
+ const results = [];
53
+ for (const item of items) {
54
+ const text = getText(item);
55
+ const match = fuzzyMatch(query, text);
56
+ if (match.matches) {
57
+ results.push({ item, score: match.score });
58
+ }
59
+ }
60
+ // Sort ascending by score (lower = better match)
61
+ results.sort((a, b) => a.score - b.score);
62
+ return results.map((r) => r.item);
63
+ }
64
+ //# sourceMappingURL=fuzzy.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fuzzy.js","sourceRoot":"","sources":["../src/fuzzy.ts"],"names":[],"mappings":"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","sourcesContent":["// Fuzzy search. Matches if all query characters appear in order (not necessarily consecutive).\n// Lower score = better match.\n\nexport interface FuzzyMatch {\n\tmatches: boolean;\n\tscore: number;\n}\n\nexport function fuzzyMatch(query: string, text: string): FuzzyMatch {\n\tconst queryLower = query.toLowerCase();\n\tconst textLower = text.toLowerCase();\n\n\tif (queryLower.length === 0) {\n\t\treturn { matches: true, score: 0 };\n\t}\n\n\tif (queryLower.length > textLower.length) {\n\t\treturn { matches: false, score: 0 };\n\t}\n\n\tlet queryIndex = 0;\n\tlet score = 0;\n\tlet lastMatchIndex = -1;\n\tlet consecutiveMatches = 0;\n\n\tfor (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {\n\t\tif (textLower[i] === queryLower[queryIndex]) {\n\t\t\tconst isWordBoundary = i === 0 || /[\\s\\-_./]/.test(textLower[i - 1]!);\n\n\t\t\t// Reward consecutive character matches (e.g., typing \"foo\" matches \"foobar\" better than \"f_o_o\")\n\t\t\tif (lastMatchIndex === i - 1) {\n\t\t\t\tconsecutiveMatches++;\n\t\t\t\tscore -= consecutiveMatches * 5;\n\t\t\t} else {\n\t\t\t\tconsecutiveMatches = 0;\n\t\t\t\t// Penalize gaps between matched characters\n\t\t\t\tif (lastMatchIndex >= 0) {\n\t\t\t\t\tscore += (i - lastMatchIndex - 1) * 2;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Reward matches at word boundaries (start of words are more likely intentional targets)\n\t\t\tif (isWordBoundary) {\n\t\t\t\tscore -= 10;\n\t\t\t}\n\n\t\t\t// Slight penalty for matches later in the string (prefer earlier matches)\n\t\t\tscore += i * 0.1;\n\n\t\t\tlastMatchIndex = i;\n\t\t\tqueryIndex++;\n\t\t}\n\t}\n\n\t// Not all query characters were found in order\n\tif (queryIndex < queryLower.length) {\n\t\treturn { matches: false, score: 0 };\n\t}\n\n\treturn { matches: true, score };\n}\n\n// Filter and sort items by fuzzy match quality (best matches first)\nexport function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {\n\tif (!query.trim()) {\n\t\treturn items;\n\t}\n\n\tconst results: { item: T; score: number }[] = [];\n\n\tfor (const item of items) {\n\t\tconst text = getText(item);\n\t\tconst match = fuzzyMatch(query, text);\n\t\tif (match.matches) {\n\t\t\tresults.push({ item, score: match.score });\n\t\t}\n\t}\n\n\t// Sort ascending by score (lower = better match)\n\tresults.sort((a, b) => a.score - b.score);\n\n\treturn results.map((r) => r.item);\n}\n"]}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=fuzzy.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fuzzy.test.d.ts","sourceRoot":"","sources":["../src/fuzzy.test.ts"],"names":[],"mappings":"","sourcesContent":["import { describe, expect, test } from \"vitest\";\nimport { fuzzyFilter, fuzzyMatch } from \"../src/fuzzy.js\";\n\ndescribe(\"fuzzyMatch\", () => {\n\ttest(\"empty query matches everything with score 0\", () => {\n\t\tconst result = fuzzyMatch(\"\", \"anything\");\n\t\texpect(result.matches).toBe(true);\n\t\texpect(result.score).toBe(0);\n\t});\n\n\ttest(\"query longer than text does not match\", () => {\n\t\tconst result = fuzzyMatch(\"longquery\", \"short\");\n\t\texpect(result.matches).toBe(false);\n\t});\n\n\ttest(\"exact match has good score\", () => {\n\t\tconst result = fuzzyMatch(\"test\", \"test\");\n\t\texpect(result.matches).toBe(true);\n\t\texpect(result.score).toBeLessThan(0); // Should be negative due to consecutive bonuses\n\t});\n\n\ttest(\"characters must appear in order\", () => {\n\t\tconst matchInOrder = fuzzyMatch(\"abc\", \"aXbXc\");\n\t\texpect(matchInOrder.matches).toBe(true);\n\n\t\tconst matchOutOfOrder = fuzzyMatch(\"abc\", \"cba\");\n\t\texpect(matchOutOfOrder.matches).toBe(false);\n\t});\n\n\ttest(\"case insensitive matching\", () => {\n\t\tconst result = fuzzyMatch(\"ABC\", \"abc\");\n\t\texpect(result.matches).toBe(true);\n\n\t\tconst result2 = fuzzyMatch(\"abc\", \"ABC\");\n\t\texpect(result2.matches).toBe(true);\n\t});\n\n\ttest(\"consecutive matches score better than scattered matches\", () => {\n\t\tconst consecutive = fuzzyMatch(\"foo\", \"foobar\");\n\t\tconst scattered = fuzzyMatch(\"foo\", \"f_o_o_bar\");\n\n\t\texpect(consecutive.matches).toBe(true);\n\t\texpect(scattered.matches).toBe(true);\n\t\texpect(consecutive.score).toBeLessThan(scattered.score);\n\t});\n\n\ttest(\"word boundary matches score better\", () => {\n\t\tconst atBoundary = fuzzyMatch(\"fb\", \"foo-bar\");\n\t\tconst notAtBoundary = fuzzyMatch(\"fb\", \"afbx\");\n\n\t\texpect(atBoundary.matches).toBe(true);\n\t\texpect(notAtBoundary.matches).toBe(true);\n\t\texpect(atBoundary.score).toBeLessThan(notAtBoundary.score);\n\t});\n});\n\ndescribe(\"fuzzyFilter\", () => {\n\ttest(\"empty query returns all items unchanged\", () => {\n\t\tconst items = [\"apple\", \"banana\", \"cherry\"];\n\t\tconst result = fuzzyFilter(items, \"\", (x) => x);\n\t\texpect(result).toEqual(items);\n\t});\n\n\ttest(\"filters out non-matching items\", () => {\n\t\tconst items = [\"apple\", \"banana\", \"cherry\"];\n\t\tconst result = fuzzyFilter(items, \"an\", (x) => x);\n\t\texpect(result).toContain(\"banana\");\n\t\texpect(result).not.toContain(\"apple\");\n\t\texpect(result).not.toContain(\"cherry\");\n\t});\n\n\ttest(\"sorts results by match quality\", () => {\n\t\tconst items = [\"a_p_p\", \"app\", \"application\"];\n\t\tconst result = fuzzyFilter(items, \"app\", (x) => x);\n\n\t\t// \"app\" should be first (exact consecutive match at start)\n\t\texpect(result[0]).toBe(\"app\");\n\t});\n\n\ttest(\"works with custom getText function\", () => {\n\t\tconst items = [\n\t\t\t{ name: \"foo\", id: 1 },\n\t\t\t{ name: \"bar\", id: 2 },\n\t\t\t{ name: \"foobar\", id: 3 },\n\t\t];\n\t\tconst result = fuzzyFilter(items, \"foo\", (item) => item.name);\n\n\t\texpect(result.length).toBe(2);\n\t\texpect(result.map((r) => r.name)).toContain(\"foo\");\n\t\texpect(result.map((r) => r.name)).toContain(\"foobar\");\n\t});\n});\n"]}
@@ -0,0 +1,76 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { fuzzyFilter, fuzzyMatch } from "../src/fuzzy.js";
3
+ describe("fuzzyMatch", () => {
4
+ test("empty query matches everything with score 0", () => {
5
+ const result = fuzzyMatch("", "anything");
6
+ expect(result.matches).toBe(true);
7
+ expect(result.score).toBe(0);
8
+ });
9
+ test("query longer than text does not match", () => {
10
+ const result = fuzzyMatch("longquery", "short");
11
+ expect(result.matches).toBe(false);
12
+ });
13
+ test("exact match has good score", () => {
14
+ const result = fuzzyMatch("test", "test");
15
+ expect(result.matches).toBe(true);
16
+ expect(result.score).toBeLessThan(0); // Should be negative due to consecutive bonuses
17
+ });
18
+ test("characters must appear in order", () => {
19
+ const matchInOrder = fuzzyMatch("abc", "aXbXc");
20
+ expect(matchInOrder.matches).toBe(true);
21
+ const matchOutOfOrder = fuzzyMatch("abc", "cba");
22
+ expect(matchOutOfOrder.matches).toBe(false);
23
+ });
24
+ test("case insensitive matching", () => {
25
+ const result = fuzzyMatch("ABC", "abc");
26
+ expect(result.matches).toBe(true);
27
+ const result2 = fuzzyMatch("abc", "ABC");
28
+ expect(result2.matches).toBe(true);
29
+ });
30
+ test("consecutive matches score better than scattered matches", () => {
31
+ const consecutive = fuzzyMatch("foo", "foobar");
32
+ const scattered = fuzzyMatch("foo", "f_o_o_bar");
33
+ expect(consecutive.matches).toBe(true);
34
+ expect(scattered.matches).toBe(true);
35
+ expect(consecutive.score).toBeLessThan(scattered.score);
36
+ });
37
+ test("word boundary matches score better", () => {
38
+ const atBoundary = fuzzyMatch("fb", "foo-bar");
39
+ const notAtBoundary = fuzzyMatch("fb", "afbx");
40
+ expect(atBoundary.matches).toBe(true);
41
+ expect(notAtBoundary.matches).toBe(true);
42
+ expect(atBoundary.score).toBeLessThan(notAtBoundary.score);
43
+ });
44
+ });
45
+ describe("fuzzyFilter", () => {
46
+ test("empty query returns all items unchanged", () => {
47
+ const items = ["apple", "banana", "cherry"];
48
+ const result = fuzzyFilter(items, "", (x) => x);
49
+ expect(result).toEqual(items);
50
+ });
51
+ test("filters out non-matching items", () => {
52
+ const items = ["apple", "banana", "cherry"];
53
+ const result = fuzzyFilter(items, "an", (x) => x);
54
+ expect(result).toContain("banana");
55
+ expect(result).not.toContain("apple");
56
+ expect(result).not.toContain("cherry");
57
+ });
58
+ test("sorts results by match quality", () => {
59
+ const items = ["a_p_p", "app", "application"];
60
+ const result = fuzzyFilter(items, "app", (x) => x);
61
+ // "app" should be first (exact consecutive match at start)
62
+ expect(result[0]).toBe("app");
63
+ });
64
+ test("works with custom getText function", () => {
65
+ const items = [
66
+ { name: "foo", id: 1 },
67
+ { name: "bar", id: 2 },
68
+ { name: "foobar", id: 3 },
69
+ ];
70
+ const result = fuzzyFilter(items, "foo", (item) => item.name);
71
+ expect(result.length).toBe(2);
72
+ expect(result.map((r) => r.name)).toContain("foo");
73
+ expect(result.map((r) => r.name)).toContain("foobar");
74
+ });
75
+ });
76
+ //# sourceMappingURL=fuzzy.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fuzzy.test.js","sourceRoot":"","sources":["../src/fuzzy.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { describe, expect, test } from \"vitest\";\nimport { fuzzyFilter, fuzzyMatch } from \"../src/fuzzy.js\";\n\ndescribe(\"fuzzyMatch\", () => {\n\ttest(\"empty query matches everything with score 0\", () => {\n\t\tconst result = fuzzyMatch(\"\", \"anything\");\n\t\texpect(result.matches).toBe(true);\n\t\texpect(result.score).toBe(0);\n\t});\n\n\ttest(\"query longer than text does not match\", () => {\n\t\tconst result = fuzzyMatch(\"longquery\", \"short\");\n\t\texpect(result.matches).toBe(false);\n\t});\n\n\ttest(\"exact match has good score\", () => {\n\t\tconst result = fuzzyMatch(\"test\", \"test\");\n\t\texpect(result.matches).toBe(true);\n\t\texpect(result.score).toBeLessThan(0); // Should be negative due to consecutive bonuses\n\t});\n\n\ttest(\"characters must appear in order\", () => {\n\t\tconst matchInOrder = fuzzyMatch(\"abc\", \"aXbXc\");\n\t\texpect(matchInOrder.matches).toBe(true);\n\n\t\tconst matchOutOfOrder = fuzzyMatch(\"abc\", \"cba\");\n\t\texpect(matchOutOfOrder.matches).toBe(false);\n\t});\n\n\ttest(\"case insensitive matching\", () => {\n\t\tconst result = fuzzyMatch(\"ABC\", \"abc\");\n\t\texpect(result.matches).toBe(true);\n\n\t\tconst result2 = fuzzyMatch(\"abc\", \"ABC\");\n\t\texpect(result2.matches).toBe(true);\n\t});\n\n\ttest(\"consecutive matches score better than scattered matches\", () => {\n\t\tconst consecutive = fuzzyMatch(\"foo\", \"foobar\");\n\t\tconst scattered = fuzzyMatch(\"foo\", \"f_o_o_bar\");\n\n\t\texpect(consecutive.matches).toBe(true);\n\t\texpect(scattered.matches).toBe(true);\n\t\texpect(consecutive.score).toBeLessThan(scattered.score);\n\t});\n\n\ttest(\"word boundary matches score better\", () => {\n\t\tconst atBoundary = fuzzyMatch(\"fb\", \"foo-bar\");\n\t\tconst notAtBoundary = fuzzyMatch(\"fb\", \"afbx\");\n\n\t\texpect(atBoundary.matches).toBe(true);\n\t\texpect(notAtBoundary.matches).toBe(true);\n\t\texpect(atBoundary.score).toBeLessThan(notAtBoundary.score);\n\t});\n});\n\ndescribe(\"fuzzyFilter\", () => {\n\ttest(\"empty query returns all items unchanged\", () => {\n\t\tconst items = [\"apple\", \"banana\", \"cherry\"];\n\t\tconst result = fuzzyFilter(items, \"\", (x) => x);\n\t\texpect(result).toEqual(items);\n\t});\n\n\ttest(\"filters out non-matching items\", () => {\n\t\tconst items = [\"apple\", \"banana\", \"cherry\"];\n\t\tconst result = fuzzyFilter(items, \"an\", (x) => x);\n\t\texpect(result).toContain(\"banana\");\n\t\texpect(result).not.toContain(\"apple\");\n\t\texpect(result).not.toContain(\"cherry\");\n\t});\n\n\ttest(\"sorts results by match quality\", () => {\n\t\tconst items = [\"a_p_p\", \"app\", \"application\"];\n\t\tconst result = fuzzyFilter(items, \"app\", (x) => x);\n\n\t\t// \"app\" should be first (exact consecutive match at start)\n\t\texpect(result[0]).toBe(\"app\");\n\t});\n\n\ttest(\"works with custom getText function\", () => {\n\t\tconst items = [\n\t\t\t{ name: \"foo\", id: 1 },\n\t\t\t{ name: \"bar\", id: 2 },\n\t\t\t{ name: \"foobar\", id: 3 },\n\t\t];\n\t\tconst result = fuzzyFilter(items, \"foo\", (item) => item.name);\n\n\t\texpect(result.length).toBe(2);\n\t\texpect(result.map((r) => r.name)).toContain(\"foo\");\n\t\texpect(result.map((r) => r.name)).toContain(\"foobar\");\n\t});\n});\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"footer.d.ts","sourceRoot":"","sources":["../../src/tui/footer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAE9D,OAAO,EAAE,KAAK,SAAS,EAAgB,MAAM,sBAAsB,CAAC;AAMpE;;GAEG;AACH,qBAAa,eAAgB,YAAW,SAAS;IAChD,OAAO,CAAC,KAAK,CAAa;IAC1B,OAAO,CAAC,YAAY,CAAwC;IAC5D,OAAO,CAAC,UAAU,CAA0B;IAC5C,OAAO,CAAC,cAAc,CAA6B;IACnD,OAAO,CAAC,kBAAkB,CAAiB;IAE3C,YAAY,KAAK,EAAE,UAAU,EAE5B;IAED,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAE5C;IAED;;;OAGG;IACH,WAAW,CAAC,cAAc,EAAE,MAAM,IAAI,GAAG,IAAI,CAG5C;IAED,OAAO,CAAC,eAAe;IAwBvB;;OAEG;IACH,OAAO,IAAI,IAAI,CAKd;IAED,WAAW,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAEnC;IAED,UAAU,IAAI,IAAI,CAGjB;IAED;;;OAGG;IACH,OAAO,CAAC,gBAAgB;IAyBxB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CA+I9B;CACD","sourcesContent":["import type { AgentState } from \"@mariozechner/pi-agent-core\";\nimport type { AssistantMessage } from \"@mariozechner/pi-ai\";\nimport { type Component, visibleWidth } from \"@mariozechner/pi-tui\";\nimport { existsSync, type FSWatcher, readFileSync, watch } from \"fs\";\nimport { join } from \"path\";\nimport { isModelUsingOAuth } from \"../model-config.js\";\nimport { theme } from \"../theme/theme.js\";\n\n/**\n * Footer component that shows pwd, token stats, and context usage\n */\nexport class FooterComponent implements Component {\n\tprivate state: AgentState;\n\tprivate cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name\n\tprivate gitWatcher: FSWatcher | null = null;\n\tprivate onBranchChange: (() => void) | null = null;\n\tprivate autoCompactEnabled: boolean = true;\n\n\tconstructor(state: AgentState) {\n\t\tthis.state = state;\n\t}\n\n\tsetAutoCompactEnabled(enabled: boolean): void {\n\t\tthis.autoCompactEnabled = enabled;\n\t}\n\n\t/**\n\t * Set up a file watcher on .git/HEAD to detect branch changes.\n\t * Call the provided callback when branch changes.\n\t */\n\twatchBranch(onBranchChange: () => void): void {\n\t\tthis.onBranchChange = onBranchChange;\n\t\tthis.setupGitWatcher();\n\t}\n\n\tprivate setupGitWatcher(): void {\n\t\t// Clean up existing watcher\n\t\tif (this.gitWatcher) {\n\t\t\tthis.gitWatcher.close();\n\t\t\tthis.gitWatcher = null;\n\t\t}\n\n\t\tconst gitHeadPath = join(process.cwd(), \".git\", \"HEAD\");\n\t\tif (!existsSync(gitHeadPath)) {\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tthis.gitWatcher = watch(gitHeadPath, () => {\n\t\t\t\tthis.cachedBranch = undefined; // Invalidate cache\n\t\t\t\tif (this.onBranchChange) {\n\t\t\t\t\tthis.onBranchChange();\n\t\t\t\t}\n\t\t\t});\n\t\t} catch {\n\t\t\t// Silently fail if we can't watch\n\t\t}\n\t}\n\n\t/**\n\t * Clean up the file watcher\n\t */\n\tdispose(): void {\n\t\tif (this.gitWatcher) {\n\t\t\tthis.gitWatcher.close();\n\t\t\tthis.gitWatcher = null;\n\t\t}\n\t}\n\n\tupdateState(state: AgentState): void {\n\t\tthis.state = state;\n\t}\n\n\tinvalidate(): void {\n\t\t// Invalidate cached branch so it gets re-read on next render\n\t\tthis.cachedBranch = undefined;\n\t}\n\n\t/**\n\t * Get current git branch by reading .git/HEAD directly.\n\t * Returns null if not in a git repo, branch name otherwise.\n\t */\n\tprivate getCurrentBranch(): string | null {\n\t\t// Return cached value if available\n\t\tif (this.cachedBranch !== undefined) {\n\t\t\treturn this.cachedBranch;\n\t\t}\n\n\t\ttry {\n\t\t\tconst gitHeadPath = join(process.cwd(), \".git\", \"HEAD\");\n\t\t\tconst content = readFileSync(gitHeadPath, \"utf8\").trim();\n\n\t\t\tif (content.startsWith(\"ref: refs/heads/\")) {\n\t\t\t\t// Normal branch: extract branch name\n\t\t\t\tthis.cachedBranch = content.slice(16);\n\t\t\t} else {\n\t\t\t\t// Detached HEAD state\n\t\t\t\tthis.cachedBranch = \"detached\";\n\t\t\t}\n\t\t} catch {\n\t\t\t// Not in a git repo or error reading file\n\t\t\tthis.cachedBranch = null;\n\t\t}\n\n\t\treturn this.cachedBranch;\n\t}\n\n\trender(width: number): string[] {\n\t\t// Calculate cumulative usage from all assistant messages\n\t\tlet totalInput = 0;\n\t\tlet totalOutput = 0;\n\t\tlet totalCacheRead = 0;\n\t\tlet totalCacheWrite = 0;\n\t\tlet totalCost = 0;\n\n\t\tfor (const message of this.state.messages) {\n\t\t\tif (message.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = message as AssistantMessage;\n\t\t\t\ttotalInput += assistantMsg.usage.input;\n\t\t\t\ttotalOutput += assistantMsg.usage.output;\n\t\t\t\ttotalCacheRead += assistantMsg.usage.cacheRead;\n\t\t\t\ttotalCacheWrite += assistantMsg.usage.cacheWrite;\n\t\t\t\ttotalCost += assistantMsg.usage.cost.total;\n\t\t\t}\n\t\t}\n\n\t\t// Get last assistant message for context percentage calculation (skip aborted messages)\n\t\tconst lastAssistantMessage = this.state.messages\n\t\t\t.slice()\n\t\t\t.reverse()\n\t\t\t.find((m) => m.role === \"assistant\" && m.stopReason !== \"aborted\") as AssistantMessage | undefined;\n\n\t\t// Calculate context percentage from last message (input + output + cacheRead + cacheWrite)\n\t\tconst contextTokens = lastAssistantMessage\n\t\t\t? lastAssistantMessage.usage.input +\n\t\t\t\tlastAssistantMessage.usage.output +\n\t\t\t\tlastAssistantMessage.usage.cacheRead +\n\t\t\t\tlastAssistantMessage.usage.cacheWrite\n\t\t\t: 0;\n\t\tconst contextWindow = this.state.model?.contextWindow || 0;\n\t\tconst contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;\n\t\tconst contextPercent = contextPercentValue.toFixed(1);\n\n\t\t// Format token counts (similar to web-ui)\n\t\tconst formatTokens = (count: number): string => {\n\t\t\tif (count < 1000) return count.toString();\n\t\t\tif (count < 10000) return (count / 1000).toFixed(1) + \"k\";\n\t\t\treturn Math.round(count / 1000) + \"k\";\n\t\t};\n\n\t\t// Replace home directory with ~\n\t\tlet pwd = process.cwd();\n\t\tconst home = process.env.HOME || process.env.USERPROFILE;\n\t\tif (home && pwd.startsWith(home)) {\n\t\t\tpwd = \"~\" + pwd.slice(home.length);\n\t\t}\n\n\t\t// Add git branch if available\n\t\tconst branch = this.getCurrentBranch();\n\t\tif (branch) {\n\t\t\tpwd = `${pwd} (${branch})`;\n\t\t}\n\n\t\t// Truncate path if too long to fit width\n\t\tconst maxPathLength = Math.max(20, width - 10); // Leave some margin\n\t\tif (pwd.length > maxPathLength) {\n\t\t\tconst start = pwd.slice(0, Math.floor(maxPathLength / 2) - 2);\n\t\t\tconst end = pwd.slice(-(Math.floor(maxPathLength / 2) - 1));\n\t\t\tpwd = `${start}...${end}`;\n\t\t}\n\n\t\t// Build stats line\n\t\tconst statsParts = [];\n\t\tif (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`);\n\t\tif (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`);\n\t\tif (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`);\n\t\tif (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`);\n\n\t\t// Show cost with \"(sub)\" indicator if using OAuth subscription\n\t\tconst usingSubscription = this.state.model ? isModelUsingOAuth(this.state.model) : false;\n\t\tif (totalCost || usingSubscription) {\n\t\t\tconst costStr = `$${totalCost.toFixed(3)}${usingSubscription ? \" (sub)\" : \"\"}`;\n\t\t\tstatsParts.push(costStr);\n\t\t}\n\n\t\t// Colorize context percentage based on usage\n\t\tlet contextPercentStr: string;\n\t\tconst autoIndicator = this.autoCompactEnabled ? \" (auto)\" : \"\";\n\t\tconst contextPercentDisplay = `${contextPercent}% of ${formatTokens(contextWindow)}${autoIndicator}`;\n\t\tif (contextPercentValue > 90) {\n\t\t\tcontextPercentStr = theme.fg(\"error\", contextPercentDisplay);\n\t\t} else if (contextPercentValue > 70) {\n\t\t\tcontextPercentStr = theme.fg(\"warning\", contextPercentDisplay);\n\t\t} else {\n\t\t\tcontextPercentStr = contextPercentDisplay;\n\t\t}\n\t\tstatsParts.push(contextPercentStr);\n\n\t\tlet statsLeft = statsParts.join(\" \");\n\n\t\t// Add model name on the right side, plus thinking level if model supports it\n\t\tconst modelName = this.state.model?.id || \"no-model\";\n\n\t\t// Add thinking level hint if model supports reasoning and thinking is enabled\n\t\tlet rightSide = modelName;\n\t\tif (this.state.model?.reasoning) {\n\t\t\tconst thinkingLevel = this.state.thinkingLevel || \"off\";\n\t\t\tif (thinkingLevel !== \"off\") {\n\t\t\t\trightSide = `${modelName} • ${thinkingLevel}`;\n\t\t\t}\n\t\t}\n\n\t\tlet statsLeftWidth = visibleWidth(statsLeft);\n\t\tconst rightSideWidth = visibleWidth(rightSide);\n\n\t\t// If statsLeft is too wide, truncate it\n\t\tif (statsLeftWidth > width) {\n\t\t\t// Truncate statsLeft to fit width (no room for right side)\n\t\t\tconst plainStatsLeft = statsLeft.replace(/\\x1b\\[[0-9;]*m/g, \"\");\n\t\t\tstatsLeft = plainStatsLeft.substring(0, width - 3) + \"...\";\n\t\t\tstatsLeftWidth = visibleWidth(statsLeft);\n\t\t}\n\n\t\t// Calculate available space for padding (minimum 2 spaces between stats and model)\n\t\tconst minPadding = 2;\n\t\tconst totalNeeded = statsLeftWidth + minPadding + rightSideWidth;\n\n\t\tlet statsLine: string;\n\t\tif (totalNeeded <= width) {\n\t\t\t// Both fit - add padding to right-align model\n\t\t\tconst padding = \" \".repeat(width - statsLeftWidth - rightSideWidth);\n\t\t\tstatsLine = statsLeft + padding + rightSide;\n\t\t} else {\n\t\t\t// Need to truncate right side\n\t\t\tconst availableForRight = width - statsLeftWidth - minPadding;\n\t\t\tif (availableForRight > 3) {\n\t\t\t\t// Truncate to fit (strip ANSI codes for length calculation, then truncate raw string)\n\t\t\t\tconst plainRightSide = rightSide.replace(/\\x1b\\[[0-9;]*m/g, \"\");\n\t\t\t\tconst truncatedPlain = plainRightSide.substring(0, availableForRight);\n\t\t\t\t// For simplicity, just use plain truncated version (loses color, but fits)\n\t\t\t\tconst padding = \" \".repeat(width - statsLeftWidth - truncatedPlain.length);\n\t\t\t\tstatsLine = statsLeft + padding + truncatedPlain;\n\t\t\t} else {\n\t\t\t\t// Not enough space for right side at all\n\t\t\t\tstatsLine = statsLeft;\n\t\t\t}\n\t\t}\n\n\t\t// Return two lines: pwd and stats\n\t\treturn [theme.fg(\"dim\", pwd), theme.fg(\"dim\", statsLine)];\n\t}\n}\n"]}
1
+ {"version":3,"file":"footer.d.ts","sourceRoot":"","sources":["../../src/tui/footer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,6BAA6B,CAAC;AAE9D,OAAO,EAAE,KAAK,SAAS,EAAgB,MAAM,sBAAsB,CAAC;AAMpE;;GAEG;AACH,qBAAa,eAAgB,YAAW,SAAS;IAChD,OAAO,CAAC,KAAK,CAAa;IAC1B,OAAO,CAAC,YAAY,CAAwC;IAC5D,OAAO,CAAC,UAAU,CAA0B;IAC5C,OAAO,CAAC,cAAc,CAA6B;IACnD,OAAO,CAAC,kBAAkB,CAAiB;IAE3C,YAAY,KAAK,EAAE,UAAU,EAE5B;IAED,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAE5C;IAED;;;OAGG;IACH,WAAW,CAAC,cAAc,EAAE,MAAM,IAAI,GAAG,IAAI,CAG5C;IAED,OAAO,CAAC,eAAe;IAwBvB;;OAEG;IACH,OAAO,IAAI,IAAI,CAKd;IAED,WAAW,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAEnC;IAED,UAAU,IAAI,IAAI,CAGjB;IAED;;;OAGG;IACH,OAAO,CAAC,gBAAgB;IAyBxB,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAiJ9B;CACD","sourcesContent":["import type { AgentState } from \"@mariozechner/pi-agent-core\";\nimport type { AssistantMessage } from \"@mariozechner/pi-ai\";\nimport { type Component, visibleWidth } from \"@mariozechner/pi-tui\";\nimport { existsSync, type FSWatcher, readFileSync, watch } from \"fs\";\nimport { join } from \"path\";\nimport { isModelUsingOAuth } from \"../model-config.js\";\nimport { theme } from \"../theme/theme.js\";\n\n/**\n * Footer component that shows pwd, token stats, and context usage\n */\nexport class FooterComponent implements Component {\n\tprivate state: AgentState;\n\tprivate cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name\n\tprivate gitWatcher: FSWatcher | null = null;\n\tprivate onBranchChange: (() => void) | null = null;\n\tprivate autoCompactEnabled: boolean = true;\n\n\tconstructor(state: AgentState) {\n\t\tthis.state = state;\n\t}\n\n\tsetAutoCompactEnabled(enabled: boolean): void {\n\t\tthis.autoCompactEnabled = enabled;\n\t}\n\n\t/**\n\t * Set up a file watcher on .git/HEAD to detect branch changes.\n\t * Call the provided callback when branch changes.\n\t */\n\twatchBranch(onBranchChange: () => void): void {\n\t\tthis.onBranchChange = onBranchChange;\n\t\tthis.setupGitWatcher();\n\t}\n\n\tprivate setupGitWatcher(): void {\n\t\t// Clean up existing watcher\n\t\tif (this.gitWatcher) {\n\t\t\tthis.gitWatcher.close();\n\t\t\tthis.gitWatcher = null;\n\t\t}\n\n\t\tconst gitHeadPath = join(process.cwd(), \".git\", \"HEAD\");\n\t\tif (!existsSync(gitHeadPath)) {\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tthis.gitWatcher = watch(gitHeadPath, () => {\n\t\t\t\tthis.cachedBranch = undefined; // Invalidate cache\n\t\t\t\tif (this.onBranchChange) {\n\t\t\t\t\tthis.onBranchChange();\n\t\t\t\t}\n\t\t\t});\n\t\t} catch {\n\t\t\t// Silently fail if we can't watch\n\t\t}\n\t}\n\n\t/**\n\t * Clean up the file watcher\n\t */\n\tdispose(): void {\n\t\tif (this.gitWatcher) {\n\t\t\tthis.gitWatcher.close();\n\t\t\tthis.gitWatcher = null;\n\t\t}\n\t}\n\n\tupdateState(state: AgentState): void {\n\t\tthis.state = state;\n\t}\n\n\tinvalidate(): void {\n\t\t// Invalidate cached branch so it gets re-read on next render\n\t\tthis.cachedBranch = undefined;\n\t}\n\n\t/**\n\t * Get current git branch by reading .git/HEAD directly.\n\t * Returns null if not in a git repo, branch name otherwise.\n\t */\n\tprivate getCurrentBranch(): string | null {\n\t\t// Return cached value if available\n\t\tif (this.cachedBranch !== undefined) {\n\t\t\treturn this.cachedBranch;\n\t\t}\n\n\t\ttry {\n\t\t\tconst gitHeadPath = join(process.cwd(), \".git\", \"HEAD\");\n\t\t\tconst content = readFileSync(gitHeadPath, \"utf8\").trim();\n\n\t\t\tif (content.startsWith(\"ref: refs/heads/\")) {\n\t\t\t\t// Normal branch: extract branch name\n\t\t\t\tthis.cachedBranch = content.slice(16);\n\t\t\t} else {\n\t\t\t\t// Detached HEAD state\n\t\t\t\tthis.cachedBranch = \"detached\";\n\t\t\t}\n\t\t} catch {\n\t\t\t// Not in a git repo or error reading file\n\t\t\tthis.cachedBranch = null;\n\t\t}\n\n\t\treturn this.cachedBranch;\n\t}\n\n\trender(width: number): string[] {\n\t\t// Calculate cumulative usage from all assistant messages\n\t\tlet totalInput = 0;\n\t\tlet totalOutput = 0;\n\t\tlet totalCacheRead = 0;\n\t\tlet totalCacheWrite = 0;\n\t\tlet totalCost = 0;\n\n\t\tfor (const message of this.state.messages) {\n\t\t\tif (message.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = message as AssistantMessage;\n\t\t\t\ttotalInput += assistantMsg.usage.input;\n\t\t\t\ttotalOutput += assistantMsg.usage.output;\n\t\t\t\ttotalCacheRead += assistantMsg.usage.cacheRead;\n\t\t\t\ttotalCacheWrite += assistantMsg.usage.cacheWrite;\n\t\t\t\ttotalCost += assistantMsg.usage.cost.total;\n\t\t\t}\n\t\t}\n\n\t\t// Get last assistant message for context percentage calculation (skip aborted messages)\n\t\tconst lastAssistantMessage = this.state.messages\n\t\t\t.slice()\n\t\t\t.reverse()\n\t\t\t.find((m) => m.role === \"assistant\" && m.stopReason !== \"aborted\") as AssistantMessage | undefined;\n\n\t\t// Calculate context percentage from last message (input + output + cacheRead + cacheWrite)\n\t\tconst contextTokens = lastAssistantMessage\n\t\t\t? lastAssistantMessage.usage.input +\n\t\t\t\tlastAssistantMessage.usage.output +\n\t\t\t\tlastAssistantMessage.usage.cacheRead +\n\t\t\t\tlastAssistantMessage.usage.cacheWrite\n\t\t\t: 0;\n\t\tconst contextWindow = this.state.model?.contextWindow || 0;\n\t\tconst contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;\n\t\tconst contextPercent = contextPercentValue.toFixed(1);\n\n\t\t// Format token counts (similar to web-ui)\n\t\tconst formatTokens = (count: number): string => {\n\t\t\tif (count < 1000) return count.toString();\n\t\t\tif (count < 10000) return (count / 1000).toFixed(1) + \"k\";\n\t\t\tif (count < 1000000) return Math.round(count / 1000) + \"k\";\n\t\t\tif (count < 10000000) return (count / 1000000).toFixed(1) + \"M\";\n\t\t\treturn Math.round(count / 1000000) + \"M\";\n\t\t};\n\n\t\t// Replace home directory with ~\n\t\tlet pwd = process.cwd();\n\t\tconst home = process.env.HOME || process.env.USERPROFILE;\n\t\tif (home && pwd.startsWith(home)) {\n\t\t\tpwd = \"~\" + pwd.slice(home.length);\n\t\t}\n\n\t\t// Add git branch if available\n\t\tconst branch = this.getCurrentBranch();\n\t\tif (branch) {\n\t\t\tpwd = `${pwd} (${branch})`;\n\t\t}\n\n\t\t// Truncate path if too long to fit width\n\t\tconst maxPathLength = Math.max(20, width - 10); // Leave some margin\n\t\tif (pwd.length > maxPathLength) {\n\t\t\tconst start = pwd.slice(0, Math.floor(maxPathLength / 2) - 2);\n\t\t\tconst end = pwd.slice(-(Math.floor(maxPathLength / 2) - 1));\n\t\t\tpwd = `${start}...${end}`;\n\t\t}\n\n\t\t// Build stats line\n\t\tconst statsParts = [];\n\t\tif (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`);\n\t\tif (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`);\n\t\tif (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`);\n\t\tif (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`);\n\n\t\t// Show cost with \"(sub)\" indicator if using OAuth subscription\n\t\tconst usingSubscription = this.state.model ? isModelUsingOAuth(this.state.model) : false;\n\t\tif (totalCost || usingSubscription) {\n\t\t\tconst costStr = `$${totalCost.toFixed(3)}${usingSubscription ? \" (sub)\" : \"\"}`;\n\t\t\tstatsParts.push(costStr);\n\t\t}\n\n\t\t// Colorize context percentage based on usage\n\t\tlet contextPercentStr: string;\n\t\tconst autoIndicator = this.autoCompactEnabled ? \" (auto)\" : \"\";\n\t\tconst contextPercentDisplay = `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;\n\t\tif (contextPercentValue > 90) {\n\t\t\tcontextPercentStr = theme.fg(\"error\", contextPercentDisplay);\n\t\t} else if (contextPercentValue > 70) {\n\t\t\tcontextPercentStr = theme.fg(\"warning\", contextPercentDisplay);\n\t\t} else {\n\t\t\tcontextPercentStr = contextPercentDisplay;\n\t\t}\n\t\tstatsParts.push(contextPercentStr);\n\n\t\tlet statsLeft = statsParts.join(\" \");\n\n\t\t// Add model name on the right side, plus thinking level if model supports it\n\t\tconst modelName = this.state.model?.id || \"no-model\";\n\n\t\t// Add thinking level hint if model supports reasoning and thinking is enabled\n\t\tlet rightSide = modelName;\n\t\tif (this.state.model?.reasoning) {\n\t\t\tconst thinkingLevel = this.state.thinkingLevel || \"off\";\n\t\t\tif (thinkingLevel !== \"off\") {\n\t\t\t\trightSide = `${modelName} • ${thinkingLevel}`;\n\t\t\t}\n\t\t}\n\n\t\tlet statsLeftWidth = visibleWidth(statsLeft);\n\t\tconst rightSideWidth = visibleWidth(rightSide);\n\n\t\t// If statsLeft is too wide, truncate it\n\t\tif (statsLeftWidth > width) {\n\t\t\t// Truncate statsLeft to fit width (no room for right side)\n\t\t\tconst plainStatsLeft = statsLeft.replace(/\\x1b\\[[0-9;]*m/g, \"\");\n\t\t\tstatsLeft = plainStatsLeft.substring(0, width - 3) + \"...\";\n\t\t\tstatsLeftWidth = visibleWidth(statsLeft);\n\t\t}\n\n\t\t// Calculate available space for padding (minimum 2 spaces between stats and model)\n\t\tconst minPadding = 2;\n\t\tconst totalNeeded = statsLeftWidth + minPadding + rightSideWidth;\n\n\t\tlet statsLine: string;\n\t\tif (totalNeeded <= width) {\n\t\t\t// Both fit - add padding to right-align model\n\t\t\tconst padding = \" \".repeat(width - statsLeftWidth - rightSideWidth);\n\t\t\tstatsLine = statsLeft + padding + rightSide;\n\t\t} else {\n\t\t\t// Need to truncate right side\n\t\t\tconst availableForRight = width - statsLeftWidth - minPadding;\n\t\t\tif (availableForRight > 3) {\n\t\t\t\t// Truncate to fit (strip ANSI codes for length calculation, then truncate raw string)\n\t\t\t\tconst plainRightSide = rightSide.replace(/\\x1b\\[[0-9;]*m/g, \"\");\n\t\t\t\tconst truncatedPlain = plainRightSide.substring(0, availableForRight);\n\t\t\t\t// For simplicity, just use plain truncated version (loses color, but fits)\n\t\t\t\tconst padding = \" \".repeat(width - statsLeftWidth - truncatedPlain.length);\n\t\t\t\tstatsLine = statsLeft + padding + truncatedPlain;\n\t\t\t} else {\n\t\t\t\t// Not enough space for right side at all\n\t\t\t\tstatsLine = statsLeft;\n\t\t\t}\n\t\t}\n\n\t\t// Return two lines: pwd and stats\n\t\treturn [theme.fg(\"dim\", pwd), theme.fg(\"dim\", statsLine)];\n\t}\n}\n"]}
@@ -129,7 +129,11 @@ export class FooterComponent {
129
129
  return count.toString();
130
130
  if (count < 10000)
131
131
  return (count / 1000).toFixed(1) + "k";
132
- return Math.round(count / 1000) + "k";
132
+ if (count < 1000000)
133
+ return Math.round(count / 1000) + "k";
134
+ if (count < 10000000)
135
+ return (count / 1000000).toFixed(1) + "M";
136
+ return Math.round(count / 1000000) + "M";
133
137
  };
134
138
  // Replace home directory with ~
135
139
  let pwd = process.cwd();
@@ -168,7 +172,7 @@ export class FooterComponent {
168
172
  // Colorize context percentage based on usage
169
173
  let contextPercentStr;
170
174
  const autoIndicator = this.autoCompactEnabled ? " (auto)" : "";
171
- const contextPercentDisplay = `${contextPercent}% of ${formatTokens(contextWindow)}${autoIndicator}`;
175
+ const contextPercentDisplay = `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;
172
176
  if (contextPercentValue > 90) {
173
177
  contextPercentStr = theme.fg("error", contextPercentDisplay);
174
178
  }
@@ -1 +1 @@
1
- {"version":3,"file":"footer.js","sourceRoot":"","sources":["../../src/tui/footer.ts"],"names":[],"mappings":"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","sourcesContent":["import type { AgentState } from \"@mariozechner/pi-agent-core\";\nimport type { AssistantMessage } from \"@mariozechner/pi-ai\";\nimport { type Component, visibleWidth } from \"@mariozechner/pi-tui\";\nimport { existsSync, type FSWatcher, readFileSync, watch } from \"fs\";\nimport { join } from \"path\";\nimport { isModelUsingOAuth } from \"../model-config.js\";\nimport { theme } from \"../theme/theme.js\";\n\n/**\n * Footer component that shows pwd, token stats, and context usage\n */\nexport class FooterComponent implements Component {\n\tprivate state: AgentState;\n\tprivate cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name\n\tprivate gitWatcher: FSWatcher | null = null;\n\tprivate onBranchChange: (() => void) | null = null;\n\tprivate autoCompactEnabled: boolean = true;\n\n\tconstructor(state: AgentState) {\n\t\tthis.state = state;\n\t}\n\n\tsetAutoCompactEnabled(enabled: boolean): void {\n\t\tthis.autoCompactEnabled = enabled;\n\t}\n\n\t/**\n\t * Set up a file watcher on .git/HEAD to detect branch changes.\n\t * Call the provided callback when branch changes.\n\t */\n\twatchBranch(onBranchChange: () => void): void {\n\t\tthis.onBranchChange = onBranchChange;\n\t\tthis.setupGitWatcher();\n\t}\n\n\tprivate setupGitWatcher(): void {\n\t\t// Clean up existing watcher\n\t\tif (this.gitWatcher) {\n\t\t\tthis.gitWatcher.close();\n\t\t\tthis.gitWatcher = null;\n\t\t}\n\n\t\tconst gitHeadPath = join(process.cwd(), \".git\", \"HEAD\");\n\t\tif (!existsSync(gitHeadPath)) {\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tthis.gitWatcher = watch(gitHeadPath, () => {\n\t\t\t\tthis.cachedBranch = undefined; // Invalidate cache\n\t\t\t\tif (this.onBranchChange) {\n\t\t\t\t\tthis.onBranchChange();\n\t\t\t\t}\n\t\t\t});\n\t\t} catch {\n\t\t\t// Silently fail if we can't watch\n\t\t}\n\t}\n\n\t/**\n\t * Clean up the file watcher\n\t */\n\tdispose(): void {\n\t\tif (this.gitWatcher) {\n\t\t\tthis.gitWatcher.close();\n\t\t\tthis.gitWatcher = null;\n\t\t}\n\t}\n\n\tupdateState(state: AgentState): void {\n\t\tthis.state = state;\n\t}\n\n\tinvalidate(): void {\n\t\t// Invalidate cached branch so it gets re-read on next render\n\t\tthis.cachedBranch = undefined;\n\t}\n\n\t/**\n\t * Get current git branch by reading .git/HEAD directly.\n\t * Returns null if not in a git repo, branch name otherwise.\n\t */\n\tprivate getCurrentBranch(): string | null {\n\t\t// Return cached value if available\n\t\tif (this.cachedBranch !== undefined) {\n\t\t\treturn this.cachedBranch;\n\t\t}\n\n\t\ttry {\n\t\t\tconst gitHeadPath = join(process.cwd(), \".git\", \"HEAD\");\n\t\t\tconst content = readFileSync(gitHeadPath, \"utf8\").trim();\n\n\t\t\tif (content.startsWith(\"ref: refs/heads/\")) {\n\t\t\t\t// Normal branch: extract branch name\n\t\t\t\tthis.cachedBranch = content.slice(16);\n\t\t\t} else {\n\t\t\t\t// Detached HEAD state\n\t\t\t\tthis.cachedBranch = \"detached\";\n\t\t\t}\n\t\t} catch {\n\t\t\t// Not in a git repo or error reading file\n\t\t\tthis.cachedBranch = null;\n\t\t}\n\n\t\treturn this.cachedBranch;\n\t}\n\n\trender(width: number): string[] {\n\t\t// Calculate cumulative usage from all assistant messages\n\t\tlet totalInput = 0;\n\t\tlet totalOutput = 0;\n\t\tlet totalCacheRead = 0;\n\t\tlet totalCacheWrite = 0;\n\t\tlet totalCost = 0;\n\n\t\tfor (const message of this.state.messages) {\n\t\t\tif (message.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = message as AssistantMessage;\n\t\t\t\ttotalInput += assistantMsg.usage.input;\n\t\t\t\ttotalOutput += assistantMsg.usage.output;\n\t\t\t\ttotalCacheRead += assistantMsg.usage.cacheRead;\n\t\t\t\ttotalCacheWrite += assistantMsg.usage.cacheWrite;\n\t\t\t\ttotalCost += assistantMsg.usage.cost.total;\n\t\t\t}\n\t\t}\n\n\t\t// Get last assistant message for context percentage calculation (skip aborted messages)\n\t\tconst lastAssistantMessage = this.state.messages\n\t\t\t.slice()\n\t\t\t.reverse()\n\t\t\t.find((m) => m.role === \"assistant\" && m.stopReason !== \"aborted\") as AssistantMessage | undefined;\n\n\t\t// Calculate context percentage from last message (input + output + cacheRead + cacheWrite)\n\t\tconst contextTokens = lastAssistantMessage\n\t\t\t? lastAssistantMessage.usage.input +\n\t\t\t\tlastAssistantMessage.usage.output +\n\t\t\t\tlastAssistantMessage.usage.cacheRead +\n\t\t\t\tlastAssistantMessage.usage.cacheWrite\n\t\t\t: 0;\n\t\tconst contextWindow = this.state.model?.contextWindow || 0;\n\t\tconst contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;\n\t\tconst contextPercent = contextPercentValue.toFixed(1);\n\n\t\t// Format token counts (similar to web-ui)\n\t\tconst formatTokens = (count: number): string => {\n\t\t\tif (count < 1000) return count.toString();\n\t\t\tif (count < 10000) return (count / 1000).toFixed(1) + \"k\";\n\t\t\treturn Math.round(count / 1000) + \"k\";\n\t\t};\n\n\t\t// Replace home directory with ~\n\t\tlet pwd = process.cwd();\n\t\tconst home = process.env.HOME || process.env.USERPROFILE;\n\t\tif (home && pwd.startsWith(home)) {\n\t\t\tpwd = \"~\" + pwd.slice(home.length);\n\t\t}\n\n\t\t// Add git branch if available\n\t\tconst branch = this.getCurrentBranch();\n\t\tif (branch) {\n\t\t\tpwd = `${pwd} (${branch})`;\n\t\t}\n\n\t\t// Truncate path if too long to fit width\n\t\tconst maxPathLength = Math.max(20, width - 10); // Leave some margin\n\t\tif (pwd.length > maxPathLength) {\n\t\t\tconst start = pwd.slice(0, Math.floor(maxPathLength / 2) - 2);\n\t\t\tconst end = pwd.slice(-(Math.floor(maxPathLength / 2) - 1));\n\t\t\tpwd = `${start}...${end}`;\n\t\t}\n\n\t\t// Build stats line\n\t\tconst statsParts = [];\n\t\tif (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`);\n\t\tif (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`);\n\t\tif (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`);\n\t\tif (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`);\n\n\t\t// Show cost with \"(sub)\" indicator if using OAuth subscription\n\t\tconst usingSubscription = this.state.model ? isModelUsingOAuth(this.state.model) : false;\n\t\tif (totalCost || usingSubscription) {\n\t\t\tconst costStr = `$${totalCost.toFixed(3)}${usingSubscription ? \" (sub)\" : \"\"}`;\n\t\t\tstatsParts.push(costStr);\n\t\t}\n\n\t\t// Colorize context percentage based on usage\n\t\tlet contextPercentStr: string;\n\t\tconst autoIndicator = this.autoCompactEnabled ? \" (auto)\" : \"\";\n\t\tconst contextPercentDisplay = `${contextPercent}% of ${formatTokens(contextWindow)}${autoIndicator}`;\n\t\tif (contextPercentValue > 90) {\n\t\t\tcontextPercentStr = theme.fg(\"error\", contextPercentDisplay);\n\t\t} else if (contextPercentValue > 70) {\n\t\t\tcontextPercentStr = theme.fg(\"warning\", contextPercentDisplay);\n\t\t} else {\n\t\t\tcontextPercentStr = contextPercentDisplay;\n\t\t}\n\t\tstatsParts.push(contextPercentStr);\n\n\t\tlet statsLeft = statsParts.join(\" \");\n\n\t\t// Add model name on the right side, plus thinking level if model supports it\n\t\tconst modelName = this.state.model?.id || \"no-model\";\n\n\t\t// Add thinking level hint if model supports reasoning and thinking is enabled\n\t\tlet rightSide = modelName;\n\t\tif (this.state.model?.reasoning) {\n\t\t\tconst thinkingLevel = this.state.thinkingLevel || \"off\";\n\t\t\tif (thinkingLevel !== \"off\") {\n\t\t\t\trightSide = `${modelName} • ${thinkingLevel}`;\n\t\t\t}\n\t\t}\n\n\t\tlet statsLeftWidth = visibleWidth(statsLeft);\n\t\tconst rightSideWidth = visibleWidth(rightSide);\n\n\t\t// If statsLeft is too wide, truncate it\n\t\tif (statsLeftWidth > width) {\n\t\t\t// Truncate statsLeft to fit width (no room for right side)\n\t\t\tconst plainStatsLeft = statsLeft.replace(/\\x1b\\[[0-9;]*m/g, \"\");\n\t\t\tstatsLeft = plainStatsLeft.substring(0, width - 3) + \"...\";\n\t\t\tstatsLeftWidth = visibleWidth(statsLeft);\n\t\t}\n\n\t\t// Calculate available space for padding (minimum 2 spaces between stats and model)\n\t\tconst minPadding = 2;\n\t\tconst totalNeeded = statsLeftWidth + minPadding + rightSideWidth;\n\n\t\tlet statsLine: string;\n\t\tif (totalNeeded <= width) {\n\t\t\t// Both fit - add padding to right-align model\n\t\t\tconst padding = \" \".repeat(width - statsLeftWidth - rightSideWidth);\n\t\t\tstatsLine = statsLeft + padding + rightSide;\n\t\t} else {\n\t\t\t// Need to truncate right side\n\t\t\tconst availableForRight = width - statsLeftWidth - minPadding;\n\t\t\tif (availableForRight > 3) {\n\t\t\t\t// Truncate to fit (strip ANSI codes for length calculation, then truncate raw string)\n\t\t\t\tconst plainRightSide = rightSide.replace(/\\x1b\\[[0-9;]*m/g, \"\");\n\t\t\t\tconst truncatedPlain = plainRightSide.substring(0, availableForRight);\n\t\t\t\t// For simplicity, just use plain truncated version (loses color, but fits)\n\t\t\t\tconst padding = \" \".repeat(width - statsLeftWidth - truncatedPlain.length);\n\t\t\t\tstatsLine = statsLeft + padding + truncatedPlain;\n\t\t\t} else {\n\t\t\t\t// Not enough space for right side at all\n\t\t\t\tstatsLine = statsLeft;\n\t\t\t}\n\t\t}\n\n\t\t// Return two lines: pwd and stats\n\t\treturn [theme.fg(\"dim\", pwd), theme.fg(\"dim\", statsLine)];\n\t}\n}\n"]}
1
+ {"version":3,"file":"footer.js","sourceRoot":"","sources":["../../src/tui/footer.ts"],"names":[],"mappings":"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","sourcesContent":["import type { AgentState } from \"@mariozechner/pi-agent-core\";\nimport type { AssistantMessage } from \"@mariozechner/pi-ai\";\nimport { type Component, visibleWidth } from \"@mariozechner/pi-tui\";\nimport { existsSync, type FSWatcher, readFileSync, watch } from \"fs\";\nimport { join } from \"path\";\nimport { isModelUsingOAuth } from \"../model-config.js\";\nimport { theme } from \"../theme/theme.js\";\n\n/**\n * Footer component that shows pwd, token stats, and context usage\n */\nexport class FooterComponent implements Component {\n\tprivate state: AgentState;\n\tprivate cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name\n\tprivate gitWatcher: FSWatcher | null = null;\n\tprivate onBranchChange: (() => void) | null = null;\n\tprivate autoCompactEnabled: boolean = true;\n\n\tconstructor(state: AgentState) {\n\t\tthis.state = state;\n\t}\n\n\tsetAutoCompactEnabled(enabled: boolean): void {\n\t\tthis.autoCompactEnabled = enabled;\n\t}\n\n\t/**\n\t * Set up a file watcher on .git/HEAD to detect branch changes.\n\t * Call the provided callback when branch changes.\n\t */\n\twatchBranch(onBranchChange: () => void): void {\n\t\tthis.onBranchChange = onBranchChange;\n\t\tthis.setupGitWatcher();\n\t}\n\n\tprivate setupGitWatcher(): void {\n\t\t// Clean up existing watcher\n\t\tif (this.gitWatcher) {\n\t\t\tthis.gitWatcher.close();\n\t\t\tthis.gitWatcher = null;\n\t\t}\n\n\t\tconst gitHeadPath = join(process.cwd(), \".git\", \"HEAD\");\n\t\tif (!existsSync(gitHeadPath)) {\n\t\t\treturn;\n\t\t}\n\n\t\ttry {\n\t\t\tthis.gitWatcher = watch(gitHeadPath, () => {\n\t\t\t\tthis.cachedBranch = undefined; // Invalidate cache\n\t\t\t\tif (this.onBranchChange) {\n\t\t\t\t\tthis.onBranchChange();\n\t\t\t\t}\n\t\t\t});\n\t\t} catch {\n\t\t\t// Silently fail if we can't watch\n\t\t}\n\t}\n\n\t/**\n\t * Clean up the file watcher\n\t */\n\tdispose(): void {\n\t\tif (this.gitWatcher) {\n\t\t\tthis.gitWatcher.close();\n\t\t\tthis.gitWatcher = null;\n\t\t}\n\t}\n\n\tupdateState(state: AgentState): void {\n\t\tthis.state = state;\n\t}\n\n\tinvalidate(): void {\n\t\t// Invalidate cached branch so it gets re-read on next render\n\t\tthis.cachedBranch = undefined;\n\t}\n\n\t/**\n\t * Get current git branch by reading .git/HEAD directly.\n\t * Returns null if not in a git repo, branch name otherwise.\n\t */\n\tprivate getCurrentBranch(): string | null {\n\t\t// Return cached value if available\n\t\tif (this.cachedBranch !== undefined) {\n\t\t\treturn this.cachedBranch;\n\t\t}\n\n\t\ttry {\n\t\t\tconst gitHeadPath = join(process.cwd(), \".git\", \"HEAD\");\n\t\t\tconst content = readFileSync(gitHeadPath, \"utf8\").trim();\n\n\t\t\tif (content.startsWith(\"ref: refs/heads/\")) {\n\t\t\t\t// Normal branch: extract branch name\n\t\t\t\tthis.cachedBranch = content.slice(16);\n\t\t\t} else {\n\t\t\t\t// Detached HEAD state\n\t\t\t\tthis.cachedBranch = \"detached\";\n\t\t\t}\n\t\t} catch {\n\t\t\t// Not in a git repo or error reading file\n\t\t\tthis.cachedBranch = null;\n\t\t}\n\n\t\treturn this.cachedBranch;\n\t}\n\n\trender(width: number): string[] {\n\t\t// Calculate cumulative usage from all assistant messages\n\t\tlet totalInput = 0;\n\t\tlet totalOutput = 0;\n\t\tlet totalCacheRead = 0;\n\t\tlet totalCacheWrite = 0;\n\t\tlet totalCost = 0;\n\n\t\tfor (const message of this.state.messages) {\n\t\t\tif (message.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = message as AssistantMessage;\n\t\t\t\ttotalInput += assistantMsg.usage.input;\n\t\t\t\ttotalOutput += assistantMsg.usage.output;\n\t\t\t\ttotalCacheRead += assistantMsg.usage.cacheRead;\n\t\t\t\ttotalCacheWrite += assistantMsg.usage.cacheWrite;\n\t\t\t\ttotalCost += assistantMsg.usage.cost.total;\n\t\t\t}\n\t\t}\n\n\t\t// Get last assistant message for context percentage calculation (skip aborted messages)\n\t\tconst lastAssistantMessage = this.state.messages\n\t\t\t.slice()\n\t\t\t.reverse()\n\t\t\t.find((m) => m.role === \"assistant\" && m.stopReason !== \"aborted\") as AssistantMessage | undefined;\n\n\t\t// Calculate context percentage from last message (input + output + cacheRead + cacheWrite)\n\t\tconst contextTokens = lastAssistantMessage\n\t\t\t? lastAssistantMessage.usage.input +\n\t\t\t\tlastAssistantMessage.usage.output +\n\t\t\t\tlastAssistantMessage.usage.cacheRead +\n\t\t\t\tlastAssistantMessage.usage.cacheWrite\n\t\t\t: 0;\n\t\tconst contextWindow = this.state.model?.contextWindow || 0;\n\t\tconst contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0;\n\t\tconst contextPercent = contextPercentValue.toFixed(1);\n\n\t\t// Format token counts (similar to web-ui)\n\t\tconst formatTokens = (count: number): string => {\n\t\t\tif (count < 1000) return count.toString();\n\t\t\tif (count < 10000) return (count / 1000).toFixed(1) + \"k\";\n\t\t\tif (count < 1000000) return Math.round(count / 1000) + \"k\";\n\t\t\tif (count < 10000000) return (count / 1000000).toFixed(1) + \"M\";\n\t\t\treturn Math.round(count / 1000000) + \"M\";\n\t\t};\n\n\t\t// Replace home directory with ~\n\t\tlet pwd = process.cwd();\n\t\tconst home = process.env.HOME || process.env.USERPROFILE;\n\t\tif (home && pwd.startsWith(home)) {\n\t\t\tpwd = \"~\" + pwd.slice(home.length);\n\t\t}\n\n\t\t// Add git branch if available\n\t\tconst branch = this.getCurrentBranch();\n\t\tif (branch) {\n\t\t\tpwd = `${pwd} (${branch})`;\n\t\t}\n\n\t\t// Truncate path if too long to fit width\n\t\tconst maxPathLength = Math.max(20, width - 10); // Leave some margin\n\t\tif (pwd.length > maxPathLength) {\n\t\t\tconst start = pwd.slice(0, Math.floor(maxPathLength / 2) - 2);\n\t\t\tconst end = pwd.slice(-(Math.floor(maxPathLength / 2) - 1));\n\t\t\tpwd = `${start}...${end}`;\n\t\t}\n\n\t\t// Build stats line\n\t\tconst statsParts = [];\n\t\tif (totalInput) statsParts.push(`↑${formatTokens(totalInput)}`);\n\t\tif (totalOutput) statsParts.push(`↓${formatTokens(totalOutput)}`);\n\t\tif (totalCacheRead) statsParts.push(`R${formatTokens(totalCacheRead)}`);\n\t\tif (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`);\n\n\t\t// Show cost with \"(sub)\" indicator if using OAuth subscription\n\t\tconst usingSubscription = this.state.model ? isModelUsingOAuth(this.state.model) : false;\n\t\tif (totalCost || usingSubscription) {\n\t\t\tconst costStr = `$${totalCost.toFixed(3)}${usingSubscription ? \" (sub)\" : \"\"}`;\n\t\t\tstatsParts.push(costStr);\n\t\t}\n\n\t\t// Colorize context percentage based on usage\n\t\tlet contextPercentStr: string;\n\t\tconst autoIndicator = this.autoCompactEnabled ? \" (auto)\" : \"\";\n\t\tconst contextPercentDisplay = `${contextPercent}%/${formatTokens(contextWindow)}${autoIndicator}`;\n\t\tif (contextPercentValue > 90) {\n\t\t\tcontextPercentStr = theme.fg(\"error\", contextPercentDisplay);\n\t\t} else if (contextPercentValue > 70) {\n\t\t\tcontextPercentStr = theme.fg(\"warning\", contextPercentDisplay);\n\t\t} else {\n\t\t\tcontextPercentStr = contextPercentDisplay;\n\t\t}\n\t\tstatsParts.push(contextPercentStr);\n\n\t\tlet statsLeft = statsParts.join(\" \");\n\n\t\t// Add model name on the right side, plus thinking level if model supports it\n\t\tconst modelName = this.state.model?.id || \"no-model\";\n\n\t\t// Add thinking level hint if model supports reasoning and thinking is enabled\n\t\tlet rightSide = modelName;\n\t\tif (this.state.model?.reasoning) {\n\t\t\tconst thinkingLevel = this.state.thinkingLevel || \"off\";\n\t\t\tif (thinkingLevel !== \"off\") {\n\t\t\t\trightSide = `${modelName} • ${thinkingLevel}`;\n\t\t\t}\n\t\t}\n\n\t\tlet statsLeftWidth = visibleWidth(statsLeft);\n\t\tconst rightSideWidth = visibleWidth(rightSide);\n\n\t\t// If statsLeft is too wide, truncate it\n\t\tif (statsLeftWidth > width) {\n\t\t\t// Truncate statsLeft to fit width (no room for right side)\n\t\t\tconst plainStatsLeft = statsLeft.replace(/\\x1b\\[[0-9;]*m/g, \"\");\n\t\t\tstatsLeft = plainStatsLeft.substring(0, width - 3) + \"...\";\n\t\t\tstatsLeftWidth = visibleWidth(statsLeft);\n\t\t}\n\n\t\t// Calculate available space for padding (minimum 2 spaces between stats and model)\n\t\tconst minPadding = 2;\n\t\tconst totalNeeded = statsLeftWidth + minPadding + rightSideWidth;\n\n\t\tlet statsLine: string;\n\t\tif (totalNeeded <= width) {\n\t\t\t// Both fit - add padding to right-align model\n\t\t\tconst padding = \" \".repeat(width - statsLeftWidth - rightSideWidth);\n\t\t\tstatsLine = statsLeft + padding + rightSide;\n\t\t} else {\n\t\t\t// Need to truncate right side\n\t\t\tconst availableForRight = width - statsLeftWidth - minPadding;\n\t\t\tif (availableForRight > 3) {\n\t\t\t\t// Truncate to fit (strip ANSI codes for length calculation, then truncate raw string)\n\t\t\t\tconst plainRightSide = rightSide.replace(/\\x1b\\[[0-9;]*m/g, \"\");\n\t\t\t\tconst truncatedPlain = plainRightSide.substring(0, availableForRight);\n\t\t\t\t// For simplicity, just use plain truncated version (loses color, but fits)\n\t\t\t\tconst padding = \" \".repeat(width - statsLeftWidth - truncatedPlain.length);\n\t\t\t\tstatsLine = statsLeft + padding + truncatedPlain;\n\t\t\t} else {\n\t\t\t\t// Not enough space for right side at all\n\t\t\t\tstatsLine = statsLeft;\n\t\t\t}\n\t\t}\n\n\t\t// Return two lines: pwd and stats\n\t\treturn [theme.fg(\"dim\", pwd), theme.fg(\"dim\", statsLine)];\n\t}\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"model-selector.d.ts","sourceRoot":"","sources":["../../src/tui/model-selector.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AACjD,OAAO,EAAE,SAAS,EAAE,KAAK,EAAgB,KAAK,GAAG,EAAE,MAAM,sBAAsB,CAAC;AAEhF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAU9D;;GAEG;AACH,qBAAa,sBAAuB,SAAQ,SAAS;IACpD,OAAO,CAAC,WAAW,CAAQ;IAC3B,OAAO,CAAC,aAAa,CAAY;IACjC,OAAO,CAAC,SAAS,CAAmB;IACpC,OAAO,CAAC,cAAc,CAAmB;IACzC,OAAO,CAAC,aAAa,CAAa;IAClC,OAAO,CAAC,YAAY,CAAoB;IACxC,OAAO,CAAC,eAAe,CAAkB;IACzC,OAAO,CAAC,gBAAgB,CAA8B;IACtD,OAAO,CAAC,gBAAgB,CAAa;IACrC,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,GAAG,CAAM;IAEjB,YACC,GAAG,EAAE,GAAG,EACR,YAAY,EAAE,KAAK,CAAC,GAAG,CAAC,GAAG,IAAI,EAC/B,eAAe,EAAE,eAAe,EAChC,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,KAAK,IAAI,EACrC,QAAQ,EAAE,MAAM,IAAI,EA+CpB;YAEa,UAAU;IAgCxB,OAAO,CAAC,YAAY;IAkBpB,OAAO,CAAC,UAAU;IAqDlB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CA2BjC;IAED,OAAO,CAAC,YAAY;IAMpB,cAAc,IAAI,KAAK,CAEtB;CACD","sourcesContent":["import type { Model } from \"@mariozechner/pi-ai\";\nimport { Container, Input, Spacer, Text, type TUI } from \"@mariozechner/pi-tui\";\nimport { getAvailableModels } from \"../model-config.js\";\nimport type { SettingsManager } from \"../settings-manager.js\";\nimport { theme } from \"../theme/theme.js\";\nimport { DynamicBorder } from \"./dynamic-border.js\";\n\ninterface ModelItem {\n\tprovider: string;\n\tid: string;\n\tmodel: Model<any>;\n}\n\n/**\n * Component that renders a model selector with search\n */\nexport class ModelSelectorComponent extends Container {\n\tprivate searchInput: Input;\n\tprivate listContainer: Container;\n\tprivate allModels: ModelItem[] = [];\n\tprivate filteredModels: ModelItem[] = [];\n\tprivate selectedIndex: number = 0;\n\tprivate currentModel: Model<any> | null;\n\tprivate settingsManager: SettingsManager;\n\tprivate onSelectCallback: (model: Model<any>) => void;\n\tprivate onCancelCallback: () => void;\n\tprivate errorMessage: string | null = null;\n\tprivate tui: TUI;\n\n\tconstructor(\n\t\ttui: TUI,\n\t\tcurrentModel: Model<any> | null,\n\t\tsettingsManager: SettingsManager,\n\t\tonSelect: (model: Model<any>) => void,\n\t\tonCancel: () => void,\n\t) {\n\t\tsuper();\n\n\t\tthis.tui = tui;\n\t\tthis.currentModel = currentModel;\n\t\tthis.settingsManager = settingsManager;\n\t\tthis.onSelectCallback = onSelect;\n\t\tthis.onCancelCallback = onCancel;\n\n\t\t// Add top border\n\t\tthis.addChild(new DynamicBorder());\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Add hint about API key filtering\n\t\tthis.addChild(\n\t\t\tnew Text(theme.fg(\"warning\", \"Only showing models with configured API keys (see README for details)\"), 0, 0),\n\t\t);\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Create search input\n\t\tthis.searchInput = new Input();\n\t\tthis.searchInput.onSubmit = () => {\n\t\t\t// Enter on search input selects the first filtered item\n\t\t\tif (this.filteredModels[this.selectedIndex]) {\n\t\t\t\tthis.handleSelect(this.filteredModels[this.selectedIndex].model);\n\t\t\t}\n\t\t};\n\t\tthis.addChild(this.searchInput);\n\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Create list container\n\t\tthis.listContainer = new Container();\n\t\tthis.addChild(this.listContainer);\n\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Add bottom border\n\t\tthis.addChild(new DynamicBorder());\n\n\t\t// Load models and do initial render\n\t\tthis.loadModels().then(() => {\n\t\t\tthis.updateList();\n\t\t\t// Request re-render after models are loaded\n\t\t\tthis.tui.requestRender();\n\t\t});\n\t}\n\n\tprivate async loadModels(): Promise<void> {\n\t\t// Load available models fresh (includes custom models from models.json)\n\t\tconst { models: availableModels, error } = await getAvailableModels();\n\n\t\t// If there's an error loading models.json, we'll show it via the \"no models\" path\n\t\t// The error will be displayed to the user\n\t\tif (error) {\n\t\t\tthis.allModels = [];\n\t\t\tthis.filteredModels = [];\n\t\t\tthis.errorMessage = error;\n\t\t\treturn;\n\t\t}\n\n\t\tconst models: ModelItem[] = availableModels.map((model) => ({\n\t\t\tprovider: model.provider,\n\t\t\tid: model.id,\n\t\t\tmodel,\n\t\t}));\n\n\t\t// Sort: current model first, then by provider\n\t\tmodels.sort((a, b) => {\n\t\t\tconst aIsCurrent = this.currentModel?.id === a.model.id && this.currentModel?.provider === a.provider;\n\t\t\tconst bIsCurrent = this.currentModel?.id === b.model.id && this.currentModel?.provider === b.provider;\n\t\t\tif (aIsCurrent && !bIsCurrent) return -1;\n\t\t\tif (!aIsCurrent && bIsCurrent) return 1;\n\t\t\treturn a.provider.localeCompare(b.provider);\n\t\t});\n\n\t\tthis.allModels = models;\n\t\tthis.filteredModels = models;\n\t}\n\n\tprivate filterModels(query: string): void {\n\t\tif (!query.trim()) {\n\t\t\tthis.filteredModels = this.allModels;\n\t\t} else {\n\t\t\tconst searchTokens = query\n\t\t\t\t.toLowerCase()\n\t\t\t\t.split(/\\s+/)\n\t\t\t\t.filter((t) => t);\n\t\t\tthis.filteredModels = this.allModels.filter(({ provider, id, model }) => {\n\t\t\t\tconst searchText = `${provider} ${id} ${model.name}`.toLowerCase();\n\t\t\t\treturn searchTokens.every((token) => searchText.includes(token));\n\t\t\t});\n\t\t}\n\n\t\tthis.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));\n\t\tthis.updateList();\n\t}\n\n\tprivate updateList(): void {\n\t\tthis.listContainer.clear();\n\n\t\tconst maxVisible = 10;\n\t\tconst startIndex = Math.max(\n\t\t\t0,\n\t\t\tMath.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredModels.length - maxVisible),\n\t\t);\n\t\tconst endIndex = Math.min(startIndex + maxVisible, this.filteredModels.length);\n\n\t\t// Show visible slice of filtered models\n\t\tfor (let i = startIndex; i < endIndex; i++) {\n\t\t\tconst item = this.filteredModels[i];\n\t\t\tif (!item) continue;\n\n\t\t\tconst isSelected = i === this.selectedIndex;\n\t\t\tconst isCurrent = this.currentModel?.id === item.model.id;\n\n\t\t\tlet line = \"\";\n\t\t\tif (isSelected) {\n\t\t\t\tconst prefix = theme.fg(\"accent\", \"→ \");\n\t\t\t\tconst modelText = `${item.id}`;\n\t\t\t\tconst providerBadge = theme.fg(\"muted\", `[${item.provider}]`);\n\t\t\t\tconst checkmark = isCurrent ? theme.fg(\"success\", \" ✓\") : \"\";\n\t\t\t\tline = prefix + theme.fg(\"accent\", modelText) + \" \" + providerBadge + checkmark;\n\t\t\t} else {\n\t\t\t\tconst modelText = ` ${item.id}`;\n\t\t\t\tconst providerBadge = theme.fg(\"muted\", `[${item.provider}]`);\n\t\t\t\tconst checkmark = isCurrent ? theme.fg(\"success\", \" ✓\") : \"\";\n\t\t\t\tline = modelText + \" \" + providerBadge + checkmark;\n\t\t\t}\n\n\t\t\tthis.listContainer.addChild(new Text(line, 0, 0));\n\t\t}\n\n\t\t// Add scroll indicator if needed\n\t\tif (startIndex > 0 || endIndex < this.filteredModels.length) {\n\t\t\tconst scrollInfo = theme.fg(\"muted\", ` (${this.selectedIndex + 1}/${this.filteredModels.length})`);\n\t\t\tthis.listContainer.addChild(new Text(scrollInfo, 0, 0));\n\t\t}\n\n\t\t// Show error message or \"no results\" if empty\n\t\tif (this.errorMessage) {\n\t\t\t// Show error in red\n\t\t\tconst errorLines = this.errorMessage.split(\"\\n\");\n\t\t\tfor (const line of errorLines) {\n\t\t\t\tthis.listContainer.addChild(new Text(theme.fg(\"error\", line), 0, 0));\n\t\t\t}\n\t\t} else if (this.filteredModels.length === 0) {\n\t\t\tthis.listContainer.addChild(new Text(theme.fg(\"muted\", \" No matching models\"), 0, 0));\n\t\t}\n\t}\n\n\thandleInput(keyData: string): void {\n\t\t// Up arrow - wrap to bottom when at top\n\t\tif (keyData === \"\\x1b[A\") {\n\t\t\tthis.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1;\n\t\t\tthis.updateList();\n\t\t}\n\t\t// Down arrow - wrap to top when at bottom\n\t\telse if (keyData === \"\\x1b[B\") {\n\t\t\tthis.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1;\n\t\t\tthis.updateList();\n\t\t}\n\t\t// Enter\n\t\telse if (keyData === \"\\r\") {\n\t\t\tconst selectedModel = this.filteredModels[this.selectedIndex];\n\t\t\tif (selectedModel) {\n\t\t\t\tthis.handleSelect(selectedModel.model);\n\t\t\t}\n\t\t}\n\t\t// Escape\n\t\telse if (keyData === \"\\x1b\") {\n\t\t\tthis.onCancelCallback();\n\t\t}\n\t\t// Pass everything else to search input\n\t\telse {\n\t\t\tthis.searchInput.handleInput(keyData);\n\t\t\tthis.filterModels(this.searchInput.getValue());\n\t\t}\n\t}\n\n\tprivate handleSelect(model: Model<any>): void {\n\t\t// Save as new default\n\t\tthis.settingsManager.setDefaultModelAndProvider(model.provider, model.id);\n\t\tthis.onSelectCallback(model);\n\t}\n\n\tgetSearchInput(): Input {\n\t\treturn this.searchInput;\n\t}\n}\n"]}
1
+ {"version":3,"file":"model-selector.d.ts","sourceRoot":"","sources":["../../src/tui/model-selector.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AACjD,OAAO,EAAE,SAAS,EAAE,KAAK,EAAgB,KAAK,GAAG,EAAE,MAAM,sBAAsB,CAAC;AAGhF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAU9D;;GAEG;AACH,qBAAa,sBAAuB,SAAQ,SAAS;IACpD,OAAO,CAAC,WAAW,CAAQ;IAC3B,OAAO,CAAC,aAAa,CAAY;IACjC,OAAO,CAAC,SAAS,CAAmB;IACpC,OAAO,CAAC,cAAc,CAAmB;IACzC,OAAO,CAAC,aAAa,CAAa;IAClC,OAAO,CAAC,YAAY,CAAoB;IACxC,OAAO,CAAC,eAAe,CAAkB;IACzC,OAAO,CAAC,gBAAgB,CAA8B;IACtD,OAAO,CAAC,gBAAgB,CAAa;IACrC,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,GAAG,CAAM;IAEjB,YACC,GAAG,EAAE,GAAG,EACR,YAAY,EAAE,KAAK,CAAC,GAAG,CAAC,GAAG,IAAI,EAC/B,eAAe,EAAE,eAAe,EAChC,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,GAAG,CAAC,KAAK,IAAI,EACrC,QAAQ,EAAE,MAAM,IAAI,EA+CpB;YAEa,UAAU;IAgCxB,OAAO,CAAC,YAAY;IAMpB,OAAO,CAAC,UAAU;IAqDlB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CA2BjC;IAED,OAAO,CAAC,YAAY;IAMpB,cAAc,IAAI,KAAK,CAEtB;CACD","sourcesContent":["import type { Model } from \"@mariozechner/pi-ai\";\nimport { Container, Input, Spacer, Text, type TUI } from \"@mariozechner/pi-tui\";\nimport { fuzzyFilter } from \"../fuzzy.js\";\nimport { getAvailableModels } from \"../model-config.js\";\nimport type { SettingsManager } from \"../settings-manager.js\";\nimport { theme } from \"../theme/theme.js\";\nimport { DynamicBorder } from \"./dynamic-border.js\";\n\ninterface ModelItem {\n\tprovider: string;\n\tid: string;\n\tmodel: Model<any>;\n}\n\n/**\n * Component that renders a model selector with search\n */\nexport class ModelSelectorComponent extends Container {\n\tprivate searchInput: Input;\n\tprivate listContainer: Container;\n\tprivate allModels: ModelItem[] = [];\n\tprivate filteredModels: ModelItem[] = [];\n\tprivate selectedIndex: number = 0;\n\tprivate currentModel: Model<any> | null;\n\tprivate settingsManager: SettingsManager;\n\tprivate onSelectCallback: (model: Model<any>) => void;\n\tprivate onCancelCallback: () => void;\n\tprivate errorMessage: string | null = null;\n\tprivate tui: TUI;\n\n\tconstructor(\n\t\ttui: TUI,\n\t\tcurrentModel: Model<any> | null,\n\t\tsettingsManager: SettingsManager,\n\t\tonSelect: (model: Model<any>) => void,\n\t\tonCancel: () => void,\n\t) {\n\t\tsuper();\n\n\t\tthis.tui = tui;\n\t\tthis.currentModel = currentModel;\n\t\tthis.settingsManager = settingsManager;\n\t\tthis.onSelectCallback = onSelect;\n\t\tthis.onCancelCallback = onCancel;\n\n\t\t// Add top border\n\t\tthis.addChild(new DynamicBorder());\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Add hint about API key filtering\n\t\tthis.addChild(\n\t\t\tnew Text(theme.fg(\"warning\", \"Only showing models with configured API keys (see README for details)\"), 0, 0),\n\t\t);\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Create search input\n\t\tthis.searchInput = new Input();\n\t\tthis.searchInput.onSubmit = () => {\n\t\t\t// Enter on search input selects the first filtered item\n\t\t\tif (this.filteredModels[this.selectedIndex]) {\n\t\t\t\tthis.handleSelect(this.filteredModels[this.selectedIndex].model);\n\t\t\t}\n\t\t};\n\t\tthis.addChild(this.searchInput);\n\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Create list container\n\t\tthis.listContainer = new Container();\n\t\tthis.addChild(this.listContainer);\n\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Add bottom border\n\t\tthis.addChild(new DynamicBorder());\n\n\t\t// Load models and do initial render\n\t\tthis.loadModels().then(() => {\n\t\t\tthis.updateList();\n\t\t\t// Request re-render after models are loaded\n\t\t\tthis.tui.requestRender();\n\t\t});\n\t}\n\n\tprivate async loadModels(): Promise<void> {\n\t\t// Load available models fresh (includes custom models from models.json)\n\t\tconst { models: availableModels, error } = await getAvailableModels();\n\n\t\t// If there's an error loading models.json, we'll show it via the \"no models\" path\n\t\t// The error will be displayed to the user\n\t\tif (error) {\n\t\t\tthis.allModels = [];\n\t\t\tthis.filteredModels = [];\n\t\t\tthis.errorMessage = error;\n\t\t\treturn;\n\t\t}\n\n\t\tconst models: ModelItem[] = availableModels.map((model) => ({\n\t\t\tprovider: model.provider,\n\t\t\tid: model.id,\n\t\t\tmodel,\n\t\t}));\n\n\t\t// Sort: current model first, then by provider\n\t\tmodels.sort((a, b) => {\n\t\t\tconst aIsCurrent = this.currentModel?.id === a.model.id && this.currentModel?.provider === a.provider;\n\t\t\tconst bIsCurrent = this.currentModel?.id === b.model.id && this.currentModel?.provider === b.provider;\n\t\t\tif (aIsCurrent && !bIsCurrent) return -1;\n\t\t\tif (!aIsCurrent && bIsCurrent) return 1;\n\t\t\treturn a.provider.localeCompare(b.provider);\n\t\t});\n\n\t\tthis.allModels = models;\n\t\tthis.filteredModels = models;\n\t}\n\n\tprivate filterModels(query: string): void {\n\t\tthis.filteredModels = fuzzyFilter(this.allModels, query, ({ provider, id }) => `${provider} ${id}`);\n\t\tthis.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));\n\t\tthis.updateList();\n\t}\n\n\tprivate updateList(): void {\n\t\tthis.listContainer.clear();\n\n\t\tconst maxVisible = 10;\n\t\tconst startIndex = Math.max(\n\t\t\t0,\n\t\t\tMath.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredModels.length - maxVisible),\n\t\t);\n\t\tconst endIndex = Math.min(startIndex + maxVisible, this.filteredModels.length);\n\n\t\t// Show visible slice of filtered models\n\t\tfor (let i = startIndex; i < endIndex; i++) {\n\t\t\tconst item = this.filteredModels[i];\n\t\t\tif (!item) continue;\n\n\t\t\tconst isSelected = i === this.selectedIndex;\n\t\t\tconst isCurrent = this.currentModel?.id === item.model.id;\n\n\t\t\tlet line = \"\";\n\t\t\tif (isSelected) {\n\t\t\t\tconst prefix = theme.fg(\"accent\", \"→ \");\n\t\t\t\tconst modelText = `${item.id}`;\n\t\t\t\tconst providerBadge = theme.fg(\"muted\", `[${item.provider}]`);\n\t\t\t\tconst checkmark = isCurrent ? theme.fg(\"success\", \" ✓\") : \"\";\n\t\t\t\tline = prefix + theme.fg(\"accent\", modelText) + \" \" + providerBadge + checkmark;\n\t\t\t} else {\n\t\t\t\tconst modelText = ` ${item.id}`;\n\t\t\t\tconst providerBadge = theme.fg(\"muted\", `[${item.provider}]`);\n\t\t\t\tconst checkmark = isCurrent ? theme.fg(\"success\", \" ✓\") : \"\";\n\t\t\t\tline = modelText + \" \" + providerBadge + checkmark;\n\t\t\t}\n\n\t\t\tthis.listContainer.addChild(new Text(line, 0, 0));\n\t\t}\n\n\t\t// Add scroll indicator if needed\n\t\tif (startIndex > 0 || endIndex < this.filteredModels.length) {\n\t\t\tconst scrollInfo = theme.fg(\"muted\", ` (${this.selectedIndex + 1}/${this.filteredModels.length})`);\n\t\t\tthis.listContainer.addChild(new Text(scrollInfo, 0, 0));\n\t\t}\n\n\t\t// Show error message or \"no results\" if empty\n\t\tif (this.errorMessage) {\n\t\t\t// Show error in red\n\t\t\tconst errorLines = this.errorMessage.split(\"\\n\");\n\t\t\tfor (const line of errorLines) {\n\t\t\t\tthis.listContainer.addChild(new Text(theme.fg(\"error\", line), 0, 0));\n\t\t\t}\n\t\t} else if (this.filteredModels.length === 0) {\n\t\t\tthis.listContainer.addChild(new Text(theme.fg(\"muted\", \" No matching models\"), 0, 0));\n\t\t}\n\t}\n\n\thandleInput(keyData: string): void {\n\t\t// Up arrow - wrap to bottom when at top\n\t\tif (keyData === \"\\x1b[A\") {\n\t\t\tthis.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1;\n\t\t\tthis.updateList();\n\t\t}\n\t\t// Down arrow - wrap to top when at bottom\n\t\telse if (keyData === \"\\x1b[B\") {\n\t\t\tthis.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1;\n\t\t\tthis.updateList();\n\t\t}\n\t\t// Enter\n\t\telse if (keyData === \"\\r\") {\n\t\t\tconst selectedModel = this.filteredModels[this.selectedIndex];\n\t\t\tif (selectedModel) {\n\t\t\t\tthis.handleSelect(selectedModel.model);\n\t\t\t}\n\t\t}\n\t\t// Escape\n\t\telse if (keyData === \"\\x1b\") {\n\t\t\tthis.onCancelCallback();\n\t\t}\n\t\t// Pass everything else to search input\n\t\telse {\n\t\t\tthis.searchInput.handleInput(keyData);\n\t\t\tthis.filterModels(this.searchInput.getValue());\n\t\t}\n\t}\n\n\tprivate handleSelect(model: Model<any>): void {\n\t\t// Save as new default\n\t\tthis.settingsManager.setDefaultModelAndProvider(model.provider, model.id);\n\t\tthis.onSelectCallback(model);\n\t}\n\n\tgetSearchInput(): Input {\n\t\treturn this.searchInput;\n\t}\n}\n"]}
@@ -1,4 +1,5 @@
1
1
  import { Container, Input, Spacer, Text } from "@mariozechner/pi-tui";
2
+ import { fuzzyFilter } from "../fuzzy.js";
2
3
  import { getAvailableModels } from "../model-config.js";
3
4
  import { theme } from "../theme/theme.js";
4
5
  import { DynamicBorder } from "./dynamic-border.js";
@@ -83,19 +84,7 @@ export class ModelSelectorComponent extends Container {
83
84
  this.filteredModels = models;
84
85
  }
85
86
  filterModels(query) {
86
- if (!query.trim()) {
87
- this.filteredModels = this.allModels;
88
- }
89
- else {
90
- const searchTokens = query
91
- .toLowerCase()
92
- .split(/\s+/)
93
- .filter((t) => t);
94
- this.filteredModels = this.allModels.filter(({ provider, id, model }) => {
95
- const searchText = `${provider} ${id} ${model.name}`.toLowerCase();
96
- return searchTokens.every((token) => searchText.includes(token));
97
- });
98
- }
87
+ this.filteredModels = fuzzyFilter(this.allModels, query, ({ provider, id }) => `${provider} ${id}`);
99
88
  this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
100
89
  this.updateList();
101
90
  }
@@ -1 +1 @@
1
- {"version":3,"file":"model-selector.js","sourceRoot":"","sources":["../../src/tui/model-selector.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Model } from \"@mariozechner/pi-ai\";\nimport { Container, Input, Spacer, Text, type TUI } from \"@mariozechner/pi-tui\";\nimport { getAvailableModels } from \"../model-config.js\";\nimport type { SettingsManager } from \"../settings-manager.js\";\nimport { theme } from \"../theme/theme.js\";\nimport { DynamicBorder } from \"./dynamic-border.js\";\n\ninterface ModelItem {\n\tprovider: string;\n\tid: string;\n\tmodel: Model<any>;\n}\n\n/**\n * Component that renders a model selector with search\n */\nexport class ModelSelectorComponent extends Container {\n\tprivate searchInput: Input;\n\tprivate listContainer: Container;\n\tprivate allModels: ModelItem[] = [];\n\tprivate filteredModels: ModelItem[] = [];\n\tprivate selectedIndex: number = 0;\n\tprivate currentModel: Model<any> | null;\n\tprivate settingsManager: SettingsManager;\n\tprivate onSelectCallback: (model: Model<any>) => void;\n\tprivate onCancelCallback: () => void;\n\tprivate errorMessage: string | null = null;\n\tprivate tui: TUI;\n\n\tconstructor(\n\t\ttui: TUI,\n\t\tcurrentModel: Model<any> | null,\n\t\tsettingsManager: SettingsManager,\n\t\tonSelect: (model: Model<any>) => void,\n\t\tonCancel: () => void,\n\t) {\n\t\tsuper();\n\n\t\tthis.tui = tui;\n\t\tthis.currentModel = currentModel;\n\t\tthis.settingsManager = settingsManager;\n\t\tthis.onSelectCallback = onSelect;\n\t\tthis.onCancelCallback = onCancel;\n\n\t\t// Add top border\n\t\tthis.addChild(new DynamicBorder());\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Add hint about API key filtering\n\t\tthis.addChild(\n\t\t\tnew Text(theme.fg(\"warning\", \"Only showing models with configured API keys (see README for details)\"), 0, 0),\n\t\t);\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Create search input\n\t\tthis.searchInput = new Input();\n\t\tthis.searchInput.onSubmit = () => {\n\t\t\t// Enter on search input selects the first filtered item\n\t\t\tif (this.filteredModels[this.selectedIndex]) {\n\t\t\t\tthis.handleSelect(this.filteredModels[this.selectedIndex].model);\n\t\t\t}\n\t\t};\n\t\tthis.addChild(this.searchInput);\n\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Create list container\n\t\tthis.listContainer = new Container();\n\t\tthis.addChild(this.listContainer);\n\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Add bottom border\n\t\tthis.addChild(new DynamicBorder());\n\n\t\t// Load models and do initial render\n\t\tthis.loadModels().then(() => {\n\t\t\tthis.updateList();\n\t\t\t// Request re-render after models are loaded\n\t\t\tthis.tui.requestRender();\n\t\t});\n\t}\n\n\tprivate async loadModels(): Promise<void> {\n\t\t// Load available models fresh (includes custom models from models.json)\n\t\tconst { models: availableModels, error } = await getAvailableModels();\n\n\t\t// If there's an error loading models.json, we'll show it via the \"no models\" path\n\t\t// The error will be displayed to the user\n\t\tif (error) {\n\t\t\tthis.allModels = [];\n\t\t\tthis.filteredModels = [];\n\t\t\tthis.errorMessage = error;\n\t\t\treturn;\n\t\t}\n\n\t\tconst models: ModelItem[] = availableModels.map((model) => ({\n\t\t\tprovider: model.provider,\n\t\t\tid: model.id,\n\t\t\tmodel,\n\t\t}));\n\n\t\t// Sort: current model first, then by provider\n\t\tmodels.sort((a, b) => {\n\t\t\tconst aIsCurrent = this.currentModel?.id === a.model.id && this.currentModel?.provider === a.provider;\n\t\t\tconst bIsCurrent = this.currentModel?.id === b.model.id && this.currentModel?.provider === b.provider;\n\t\t\tif (aIsCurrent && !bIsCurrent) return -1;\n\t\t\tif (!aIsCurrent && bIsCurrent) return 1;\n\t\t\treturn a.provider.localeCompare(b.provider);\n\t\t});\n\n\t\tthis.allModels = models;\n\t\tthis.filteredModels = models;\n\t}\n\n\tprivate filterModels(query: string): void {\n\t\tif (!query.trim()) {\n\t\t\tthis.filteredModels = this.allModels;\n\t\t} else {\n\t\t\tconst searchTokens = query\n\t\t\t\t.toLowerCase()\n\t\t\t\t.split(/\\s+/)\n\t\t\t\t.filter((t) => t);\n\t\t\tthis.filteredModels = this.allModels.filter(({ provider, id, model }) => {\n\t\t\t\tconst searchText = `${provider} ${id} ${model.name}`.toLowerCase();\n\t\t\t\treturn searchTokens.every((token) => searchText.includes(token));\n\t\t\t});\n\t\t}\n\n\t\tthis.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));\n\t\tthis.updateList();\n\t}\n\n\tprivate updateList(): void {\n\t\tthis.listContainer.clear();\n\n\t\tconst maxVisible = 10;\n\t\tconst startIndex = Math.max(\n\t\t\t0,\n\t\t\tMath.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredModels.length - maxVisible),\n\t\t);\n\t\tconst endIndex = Math.min(startIndex + maxVisible, this.filteredModels.length);\n\n\t\t// Show visible slice of filtered models\n\t\tfor (let i = startIndex; i < endIndex; i++) {\n\t\t\tconst item = this.filteredModels[i];\n\t\t\tif (!item) continue;\n\n\t\t\tconst isSelected = i === this.selectedIndex;\n\t\t\tconst isCurrent = this.currentModel?.id === item.model.id;\n\n\t\t\tlet line = \"\";\n\t\t\tif (isSelected) {\n\t\t\t\tconst prefix = theme.fg(\"accent\", \"→ \");\n\t\t\t\tconst modelText = `${item.id}`;\n\t\t\t\tconst providerBadge = theme.fg(\"muted\", `[${item.provider}]`);\n\t\t\t\tconst checkmark = isCurrent ? theme.fg(\"success\", \" ✓\") : \"\";\n\t\t\t\tline = prefix + theme.fg(\"accent\", modelText) + \" \" + providerBadge + checkmark;\n\t\t\t} else {\n\t\t\t\tconst modelText = ` ${item.id}`;\n\t\t\t\tconst providerBadge = theme.fg(\"muted\", `[${item.provider}]`);\n\t\t\t\tconst checkmark = isCurrent ? theme.fg(\"success\", \" ✓\") : \"\";\n\t\t\t\tline = modelText + \" \" + providerBadge + checkmark;\n\t\t\t}\n\n\t\t\tthis.listContainer.addChild(new Text(line, 0, 0));\n\t\t}\n\n\t\t// Add scroll indicator if needed\n\t\tif (startIndex > 0 || endIndex < this.filteredModels.length) {\n\t\t\tconst scrollInfo = theme.fg(\"muted\", ` (${this.selectedIndex + 1}/${this.filteredModels.length})`);\n\t\t\tthis.listContainer.addChild(new Text(scrollInfo, 0, 0));\n\t\t}\n\n\t\t// Show error message or \"no results\" if empty\n\t\tif (this.errorMessage) {\n\t\t\t// Show error in red\n\t\t\tconst errorLines = this.errorMessage.split(\"\\n\");\n\t\t\tfor (const line of errorLines) {\n\t\t\t\tthis.listContainer.addChild(new Text(theme.fg(\"error\", line), 0, 0));\n\t\t\t}\n\t\t} else if (this.filteredModels.length === 0) {\n\t\t\tthis.listContainer.addChild(new Text(theme.fg(\"muted\", \" No matching models\"), 0, 0));\n\t\t}\n\t}\n\n\thandleInput(keyData: string): void {\n\t\t// Up arrow - wrap to bottom when at top\n\t\tif (keyData === \"\\x1b[A\") {\n\t\t\tthis.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1;\n\t\t\tthis.updateList();\n\t\t}\n\t\t// Down arrow - wrap to top when at bottom\n\t\telse if (keyData === \"\\x1b[B\") {\n\t\t\tthis.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1;\n\t\t\tthis.updateList();\n\t\t}\n\t\t// Enter\n\t\telse if (keyData === \"\\r\") {\n\t\t\tconst selectedModel = this.filteredModels[this.selectedIndex];\n\t\t\tif (selectedModel) {\n\t\t\t\tthis.handleSelect(selectedModel.model);\n\t\t\t}\n\t\t}\n\t\t// Escape\n\t\telse if (keyData === \"\\x1b\") {\n\t\t\tthis.onCancelCallback();\n\t\t}\n\t\t// Pass everything else to search input\n\t\telse {\n\t\t\tthis.searchInput.handleInput(keyData);\n\t\t\tthis.filterModels(this.searchInput.getValue());\n\t\t}\n\t}\n\n\tprivate handleSelect(model: Model<any>): void {\n\t\t// Save as new default\n\t\tthis.settingsManager.setDefaultModelAndProvider(model.provider, model.id);\n\t\tthis.onSelectCallback(model);\n\t}\n\n\tgetSearchInput(): Input {\n\t\treturn this.searchInput;\n\t}\n}\n"]}
1
+ {"version":3,"file":"model-selector.js","sourceRoot":"","sources":["../../src/tui/model-selector.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Model } from \"@mariozechner/pi-ai\";\nimport { Container, Input, Spacer, Text, type TUI } from \"@mariozechner/pi-tui\";\nimport { fuzzyFilter } from \"../fuzzy.js\";\nimport { getAvailableModels } from \"../model-config.js\";\nimport type { SettingsManager } from \"../settings-manager.js\";\nimport { theme } from \"../theme/theme.js\";\nimport { DynamicBorder } from \"./dynamic-border.js\";\n\ninterface ModelItem {\n\tprovider: string;\n\tid: string;\n\tmodel: Model<any>;\n}\n\n/**\n * Component that renders a model selector with search\n */\nexport class ModelSelectorComponent extends Container {\n\tprivate searchInput: Input;\n\tprivate listContainer: Container;\n\tprivate allModels: ModelItem[] = [];\n\tprivate filteredModels: ModelItem[] = [];\n\tprivate selectedIndex: number = 0;\n\tprivate currentModel: Model<any> | null;\n\tprivate settingsManager: SettingsManager;\n\tprivate onSelectCallback: (model: Model<any>) => void;\n\tprivate onCancelCallback: () => void;\n\tprivate errorMessage: string | null = null;\n\tprivate tui: TUI;\n\n\tconstructor(\n\t\ttui: TUI,\n\t\tcurrentModel: Model<any> | null,\n\t\tsettingsManager: SettingsManager,\n\t\tonSelect: (model: Model<any>) => void,\n\t\tonCancel: () => void,\n\t) {\n\t\tsuper();\n\n\t\tthis.tui = tui;\n\t\tthis.currentModel = currentModel;\n\t\tthis.settingsManager = settingsManager;\n\t\tthis.onSelectCallback = onSelect;\n\t\tthis.onCancelCallback = onCancel;\n\n\t\t// Add top border\n\t\tthis.addChild(new DynamicBorder());\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Add hint about API key filtering\n\t\tthis.addChild(\n\t\t\tnew Text(theme.fg(\"warning\", \"Only showing models with configured API keys (see README for details)\"), 0, 0),\n\t\t);\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Create search input\n\t\tthis.searchInput = new Input();\n\t\tthis.searchInput.onSubmit = () => {\n\t\t\t// Enter on search input selects the first filtered item\n\t\t\tif (this.filteredModels[this.selectedIndex]) {\n\t\t\t\tthis.handleSelect(this.filteredModels[this.selectedIndex].model);\n\t\t\t}\n\t\t};\n\t\tthis.addChild(this.searchInput);\n\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Create list container\n\t\tthis.listContainer = new Container();\n\t\tthis.addChild(this.listContainer);\n\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Add bottom border\n\t\tthis.addChild(new DynamicBorder());\n\n\t\t// Load models and do initial render\n\t\tthis.loadModels().then(() => {\n\t\t\tthis.updateList();\n\t\t\t// Request re-render after models are loaded\n\t\t\tthis.tui.requestRender();\n\t\t});\n\t}\n\n\tprivate async loadModels(): Promise<void> {\n\t\t// Load available models fresh (includes custom models from models.json)\n\t\tconst { models: availableModels, error } = await getAvailableModels();\n\n\t\t// If there's an error loading models.json, we'll show it via the \"no models\" path\n\t\t// The error will be displayed to the user\n\t\tif (error) {\n\t\t\tthis.allModels = [];\n\t\t\tthis.filteredModels = [];\n\t\t\tthis.errorMessage = error;\n\t\t\treturn;\n\t\t}\n\n\t\tconst models: ModelItem[] = availableModels.map((model) => ({\n\t\t\tprovider: model.provider,\n\t\t\tid: model.id,\n\t\t\tmodel,\n\t\t}));\n\n\t\t// Sort: current model first, then by provider\n\t\tmodels.sort((a, b) => {\n\t\t\tconst aIsCurrent = this.currentModel?.id === a.model.id && this.currentModel?.provider === a.provider;\n\t\t\tconst bIsCurrent = this.currentModel?.id === b.model.id && this.currentModel?.provider === b.provider;\n\t\t\tif (aIsCurrent && !bIsCurrent) return -1;\n\t\t\tif (!aIsCurrent && bIsCurrent) return 1;\n\t\t\treturn a.provider.localeCompare(b.provider);\n\t\t});\n\n\t\tthis.allModels = models;\n\t\tthis.filteredModels = models;\n\t}\n\n\tprivate filterModels(query: string): void {\n\t\tthis.filteredModels = fuzzyFilter(this.allModels, query, ({ provider, id }) => `${provider} ${id}`);\n\t\tthis.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));\n\t\tthis.updateList();\n\t}\n\n\tprivate updateList(): void {\n\t\tthis.listContainer.clear();\n\n\t\tconst maxVisible = 10;\n\t\tconst startIndex = Math.max(\n\t\t\t0,\n\t\t\tMath.min(this.selectedIndex - Math.floor(maxVisible / 2), this.filteredModels.length - maxVisible),\n\t\t);\n\t\tconst endIndex = Math.min(startIndex + maxVisible, this.filteredModels.length);\n\n\t\t// Show visible slice of filtered models\n\t\tfor (let i = startIndex; i < endIndex; i++) {\n\t\t\tconst item = this.filteredModels[i];\n\t\t\tif (!item) continue;\n\n\t\t\tconst isSelected = i === this.selectedIndex;\n\t\t\tconst isCurrent = this.currentModel?.id === item.model.id;\n\n\t\t\tlet line = \"\";\n\t\t\tif (isSelected) {\n\t\t\t\tconst prefix = theme.fg(\"accent\", \"→ \");\n\t\t\t\tconst modelText = `${item.id}`;\n\t\t\t\tconst providerBadge = theme.fg(\"muted\", `[${item.provider}]`);\n\t\t\t\tconst checkmark = isCurrent ? theme.fg(\"success\", \" ✓\") : \"\";\n\t\t\t\tline = prefix + theme.fg(\"accent\", modelText) + \" \" + providerBadge + checkmark;\n\t\t\t} else {\n\t\t\t\tconst modelText = ` ${item.id}`;\n\t\t\t\tconst providerBadge = theme.fg(\"muted\", `[${item.provider}]`);\n\t\t\t\tconst checkmark = isCurrent ? theme.fg(\"success\", \" ✓\") : \"\";\n\t\t\t\tline = modelText + \" \" + providerBadge + checkmark;\n\t\t\t}\n\n\t\t\tthis.listContainer.addChild(new Text(line, 0, 0));\n\t\t}\n\n\t\t// Add scroll indicator if needed\n\t\tif (startIndex > 0 || endIndex < this.filteredModels.length) {\n\t\t\tconst scrollInfo = theme.fg(\"muted\", ` (${this.selectedIndex + 1}/${this.filteredModels.length})`);\n\t\t\tthis.listContainer.addChild(new Text(scrollInfo, 0, 0));\n\t\t}\n\n\t\t// Show error message or \"no results\" if empty\n\t\tif (this.errorMessage) {\n\t\t\t// Show error in red\n\t\t\tconst errorLines = this.errorMessage.split(\"\\n\");\n\t\t\tfor (const line of errorLines) {\n\t\t\t\tthis.listContainer.addChild(new Text(theme.fg(\"error\", line), 0, 0));\n\t\t\t}\n\t\t} else if (this.filteredModels.length === 0) {\n\t\t\tthis.listContainer.addChild(new Text(theme.fg(\"muted\", \" No matching models\"), 0, 0));\n\t\t}\n\t}\n\n\thandleInput(keyData: string): void {\n\t\t// Up arrow - wrap to bottom when at top\n\t\tif (keyData === \"\\x1b[A\") {\n\t\t\tthis.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1;\n\t\t\tthis.updateList();\n\t\t}\n\t\t// Down arrow - wrap to top when at bottom\n\t\telse if (keyData === \"\\x1b[B\") {\n\t\t\tthis.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1;\n\t\t\tthis.updateList();\n\t\t}\n\t\t// Enter\n\t\telse if (keyData === \"\\r\") {\n\t\t\tconst selectedModel = this.filteredModels[this.selectedIndex];\n\t\t\tif (selectedModel) {\n\t\t\t\tthis.handleSelect(selectedModel.model);\n\t\t\t}\n\t\t}\n\t\t// Escape\n\t\telse if (keyData === \"\\x1b\") {\n\t\t\tthis.onCancelCallback();\n\t\t}\n\t\t// Pass everything else to search input\n\t\telse {\n\t\t\tthis.searchInput.handleInput(keyData);\n\t\t\tthis.filterModels(this.searchInput.getValue());\n\t\t}\n\t}\n\n\tprivate handleSelect(model: Model<any>): void {\n\t\t// Save as new default\n\t\tthis.settingsManager.setDefaultModelAndProvider(model.provider, model.id);\n\t\tthis.onSelectCallback(model);\n\t}\n\n\tgetSearchInput(): Input {\n\t\treturn this.searchInput;\n\t}\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"session-selector.d.ts","sourceRoot":"","sources":["../../src/tui/session-selector.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAE,SAAS,EAAwC,MAAM,sBAAsB,CAAC;AACvG,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAI5D,UAAU,WAAW;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,IAAI,CAAC;IACd,QAAQ,EAAE,IAAI,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;CACxB;AAED;;GAEG;AACH,cAAM,WAAY,YAAW,SAAS;IACrC,OAAO,CAAC,WAAW,CAAqB;IACxC,OAAO,CAAC,gBAAgB,CAAqB;IAC7C,OAAO,CAAC,aAAa,CAAa;IAClC,OAAO,CAAC,WAAW,CAAQ;IACpB,QAAQ,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IAC7B,OAAO,CAAC,UAAU,CAAa;IAE/B,YAAY,QAAQ,EAAE,WAAW,EAAE,EAclC;IAED,OAAO,CAAC,cAAc;IAkBtB,UAAU,IAAI,IAAI,CAEjB;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAqE9B;IAED,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CA+BjC;CACD;AAED;;GAEG;AACH,qBAAa,wBAAyB,SAAQ,SAAS;IACtD,OAAO,CAAC,WAAW,CAAc;IAEjC,YAAY,cAAc,EAAE,cAAc,EAAE,QAAQ,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,EAAE,QAAQ,EAAE,MAAM,IAAI,EA4BxG;IAED,cAAc,IAAI,WAAW,CAE5B;CACD","sourcesContent":["import { type Component, Container, Input, Spacer, Text, truncateToWidth } from \"@mariozechner/pi-tui\";\nimport type { SessionManager } from \"../session-manager.js\";\nimport { theme } from \"../theme/theme.js\";\nimport { DynamicBorder } from \"./dynamic-border.js\";\n\ninterface SessionItem {\n\tpath: string;\n\tid: string;\n\tcreated: Date;\n\tmodified: Date;\n\tmessageCount: number;\n\tfirstMessage: string;\n\tallMessagesText: string;\n}\n\n/**\n * Custom session list component with multi-line items and search\n */\nclass SessionList implements Component {\n\tprivate allSessions: SessionItem[] = [];\n\tprivate filteredSessions: SessionItem[] = [];\n\tprivate selectedIndex: number = 0;\n\tprivate searchInput: Input;\n\tpublic onSelect?: (sessionPath: string) => void;\n\tpublic onCancel?: () => void;\n\tprivate maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank)\n\n\tconstructor(sessions: SessionItem[]) {\n\t\tthis.allSessions = sessions;\n\t\tthis.filteredSessions = sessions;\n\t\tthis.searchInput = new Input();\n\n\t\t// Handle Enter in search input - select current item\n\t\tthis.searchInput.onSubmit = () => {\n\t\t\tif (this.filteredSessions[this.selectedIndex]) {\n\t\t\t\tconst selected = this.filteredSessions[this.selectedIndex];\n\t\t\t\tif (this.onSelect) {\n\t\t\t\t\tthis.onSelect(selected.path);\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\t}\n\n\tprivate filterSessions(query: string): void {\n\t\tif (!query.trim()) {\n\t\t\tthis.filteredSessions = this.allSessions;\n\t\t} else {\n\t\t\tconst searchTokens = query\n\t\t\t\t.toLowerCase()\n\t\t\t\t.split(/\\s+/)\n\t\t\t\t.filter((t) => t);\n\t\t\tthis.filteredSessions = this.allSessions.filter((session) => {\n\t\t\t\t// Search through all messages in the session\n\t\t\t\tconst searchText = session.allMessagesText.toLowerCase();\n\t\t\t\treturn searchTokens.every((token) => searchText.includes(token));\n\t\t\t});\n\t\t}\n\n\t\tthis.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1));\n\t}\n\n\tinvalidate(): void {\n\t\t// No cached state to invalidate currently\n\t}\n\n\trender(width: number): string[] {\n\t\tconst lines: string[] = [];\n\n\t\t// Render search input\n\t\tlines.push(...this.searchInput.render(width));\n\t\tlines.push(\"\"); // Blank line after search\n\n\t\tif (this.filteredSessions.length === 0) {\n\t\t\tlines.push(theme.fg(\"muted\", \" No sessions found\"));\n\t\t\treturn lines;\n\t\t}\n\n\t\t// Format dates\n\t\tconst formatDate = (date: Date): string => {\n\t\t\tconst now = new Date();\n\t\t\tconst diffMs = now.getTime() - date.getTime();\n\t\t\tconst diffMins = Math.floor(diffMs / 60000);\n\t\t\tconst diffHours = Math.floor(diffMs / 3600000);\n\t\t\tconst diffDays = Math.floor(diffMs / 86400000);\n\n\t\t\tif (diffMins < 1) return \"just now\";\n\t\t\tif (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? \"s\" : \"\"} ago`;\n\t\t\tif (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? \"s\" : \"\"} ago`;\n\t\t\tif (diffDays === 1) return \"1 day ago\";\n\t\t\tif (diffDays < 7) return `${diffDays} days ago`;\n\n\t\t\treturn date.toLocaleDateString();\n\t\t};\n\n\t\t// Calculate visible range with scrolling\n\t\tconst startIndex = Math.max(\n\t\t\t0,\n\t\t\tMath.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredSessions.length - this.maxVisible),\n\t\t);\n\t\tconst endIndex = Math.min(startIndex + this.maxVisible, this.filteredSessions.length);\n\n\t\t// Render visible sessions (2 lines per session + blank line)\n\t\tfor (let i = startIndex; i < endIndex; i++) {\n\t\t\tconst session = this.filteredSessions[i];\n\t\t\tconst isSelected = i === this.selectedIndex;\n\n\t\t\t// Normalize first message to single line\n\t\t\tconst normalizedMessage = session.firstMessage.replace(/\\n/g, \" \").trim();\n\n\t\t\t// First line: cursor + message (truncate to visible width)\n\t\t\tconst cursor = isSelected ? theme.fg(\"accent\", \"› \") : \" \";\n\t\t\tconst maxMsgWidth = width - 2; // Account for cursor (2 visible chars)\n\t\t\tconst truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth, \"...\");\n\t\t\tconst messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg);\n\n\t\t\t// Second line: metadata (dimmed) - also truncate for safety\n\t\t\tconst modified = formatDate(session.modified);\n\t\t\tconst msgCount = `${session.messageCount} message${session.messageCount !== 1 ? \"s\" : \"\"}`;\n\t\t\tconst metadata = ` ${modified} · ${msgCount}`;\n\t\t\tconst metadataLine = theme.fg(\"dim\", truncateToWidth(metadata, width, \"\"));\n\n\t\t\tlines.push(messageLine);\n\t\t\tlines.push(metadataLine);\n\t\t\tlines.push(\"\"); // Blank line between sessions\n\t\t}\n\n\t\t// Add scroll indicator if needed\n\t\tif (startIndex > 0 || endIndex < this.filteredSessions.length) {\n\t\t\tconst scrollText = ` (${this.selectedIndex + 1}/${this.filteredSessions.length})`;\n\t\t\tconst scrollInfo = theme.fg(\"muted\", truncateToWidth(scrollText, width, \"\"));\n\t\t\tlines.push(scrollInfo);\n\t\t}\n\n\t\treturn lines;\n\t}\n\n\thandleInput(keyData: string): void {\n\t\t// Up arrow\n\t\tif (keyData === \"\\x1b[A\") {\n\t\t\tthis.selectedIndex = Math.max(0, this.selectedIndex - 1);\n\t\t}\n\t\t// Down arrow\n\t\telse if (keyData === \"\\x1b[B\") {\n\t\t\tthis.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + 1);\n\t\t}\n\t\t// Enter\n\t\telse if (keyData === \"\\r\") {\n\t\t\tconst selected = this.filteredSessions[this.selectedIndex];\n\t\t\tif (selected && this.onSelect) {\n\t\t\t\tthis.onSelect(selected.path);\n\t\t\t}\n\t\t}\n\t\t// Escape - cancel\n\t\telse if (keyData === \"\\x1b\") {\n\t\t\tif (this.onCancel) {\n\t\t\t\tthis.onCancel();\n\t\t\t}\n\t\t}\n\t\t// Ctrl+C - exit process\n\t\telse if (keyData === \"\\x03\") {\n\t\t\tprocess.exit(0);\n\t\t}\n\t\t// Pass everything else to search input\n\t\telse {\n\t\t\tthis.searchInput.handleInput(keyData);\n\t\t\tthis.filterSessions(this.searchInput.getValue());\n\t\t}\n\t}\n}\n\n/**\n * Component that renders a session selector\n */\nexport class SessionSelectorComponent extends Container {\n\tprivate sessionList: SessionList;\n\n\tconstructor(sessionManager: SessionManager, onSelect: (sessionPath: string) => void, onCancel: () => void) {\n\t\tsuper();\n\n\t\t// Load all sessions\n\t\tconst sessions = sessionManager.loadAllSessions();\n\n\t\t// Add header\n\t\tthis.addChild(new Spacer(1));\n\t\tthis.addChild(new Text(theme.bold(\"Resume Session\"), 1, 0));\n\t\tthis.addChild(new Spacer(1));\n\t\tthis.addChild(new DynamicBorder());\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Create session list\n\t\tthis.sessionList = new SessionList(sessions);\n\t\tthis.sessionList.onSelect = onSelect;\n\t\tthis.sessionList.onCancel = onCancel;\n\n\t\tthis.addChild(this.sessionList);\n\n\t\t// Add bottom border\n\t\tthis.addChild(new Spacer(1));\n\t\tthis.addChild(new DynamicBorder());\n\n\t\t// Auto-cancel if no sessions\n\t\tif (sessions.length === 0) {\n\t\t\tsetTimeout(() => onCancel(), 100);\n\t\t}\n\t}\n\n\tgetSessionList(): SessionList {\n\t\treturn this.sessionList;\n\t}\n}\n"]}
1
+ {"version":3,"file":"session-selector.d.ts","sourceRoot":"","sources":["../../src/tui/session-selector.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAE,SAAS,EAAwC,MAAM,sBAAsB,CAAC;AAEvG,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AAI5D,UAAU,WAAW;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,IAAI,CAAC;IACd,QAAQ,EAAE,IAAI,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,CAAC;CACxB;AAED;;GAEG;AACH,cAAM,WAAY,YAAW,SAAS;IACrC,OAAO,CAAC,WAAW,CAAqB;IACxC,OAAO,CAAC,gBAAgB,CAAqB;IAC7C,OAAO,CAAC,aAAa,CAAa;IAClC,OAAO,CAAC,WAAW,CAAQ;IACpB,QAAQ,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IAC7B,OAAO,CAAC,UAAU,CAAa;IAE/B,YAAY,QAAQ,EAAE,WAAW,EAAE,EAclC;IAED,OAAO,CAAC,cAAc;IAKtB,UAAU,IAAI,IAAI,CAEjB;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAqE9B;IAED,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CA+BjC;CACD;AAED;;GAEG;AACH,qBAAa,wBAAyB,SAAQ,SAAS;IACtD,OAAO,CAAC,WAAW,CAAc;IAEjC,YAAY,cAAc,EAAE,cAAc,EAAE,QAAQ,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,EAAE,QAAQ,EAAE,MAAM,IAAI,EA4BxG;IAED,cAAc,IAAI,WAAW,CAE5B;CACD","sourcesContent":["import { type Component, Container, Input, Spacer, Text, truncateToWidth } from \"@mariozechner/pi-tui\";\nimport { fuzzyFilter } from \"../fuzzy.js\";\nimport type { SessionManager } from \"../session-manager.js\";\nimport { theme } from \"../theme/theme.js\";\nimport { DynamicBorder } from \"./dynamic-border.js\";\n\ninterface SessionItem {\n\tpath: string;\n\tid: string;\n\tcreated: Date;\n\tmodified: Date;\n\tmessageCount: number;\n\tfirstMessage: string;\n\tallMessagesText: string;\n}\n\n/**\n * Custom session list component with multi-line items and search\n */\nclass SessionList implements Component {\n\tprivate allSessions: SessionItem[] = [];\n\tprivate filteredSessions: SessionItem[] = [];\n\tprivate selectedIndex: number = 0;\n\tprivate searchInput: Input;\n\tpublic onSelect?: (sessionPath: string) => void;\n\tpublic onCancel?: () => void;\n\tprivate maxVisible: number = 5; // Max sessions visible (each session is 3 lines: msg + metadata + blank)\n\n\tconstructor(sessions: SessionItem[]) {\n\t\tthis.allSessions = sessions;\n\t\tthis.filteredSessions = sessions;\n\t\tthis.searchInput = new Input();\n\n\t\t// Handle Enter in search input - select current item\n\t\tthis.searchInput.onSubmit = () => {\n\t\t\tif (this.filteredSessions[this.selectedIndex]) {\n\t\t\t\tconst selected = this.filteredSessions[this.selectedIndex];\n\t\t\t\tif (this.onSelect) {\n\t\t\t\t\tthis.onSelect(selected.path);\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\t}\n\n\tprivate filterSessions(query: string): void {\n\t\tthis.filteredSessions = fuzzyFilter(this.allSessions, query, (session) => session.allMessagesText);\n\t\tthis.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1));\n\t}\n\n\tinvalidate(): void {\n\t\t// No cached state to invalidate currently\n\t}\n\n\trender(width: number): string[] {\n\t\tconst lines: string[] = [];\n\n\t\t// Render search input\n\t\tlines.push(...this.searchInput.render(width));\n\t\tlines.push(\"\"); // Blank line after search\n\n\t\tif (this.filteredSessions.length === 0) {\n\t\t\tlines.push(theme.fg(\"muted\", \" No sessions found\"));\n\t\t\treturn lines;\n\t\t}\n\n\t\t// Format dates\n\t\tconst formatDate = (date: Date): string => {\n\t\t\tconst now = new Date();\n\t\t\tconst diffMs = now.getTime() - date.getTime();\n\t\t\tconst diffMins = Math.floor(diffMs / 60000);\n\t\t\tconst diffHours = Math.floor(diffMs / 3600000);\n\t\t\tconst diffDays = Math.floor(diffMs / 86400000);\n\n\t\t\tif (diffMins < 1) return \"just now\";\n\t\t\tif (diffMins < 60) return `${diffMins} minute${diffMins !== 1 ? \"s\" : \"\"} ago`;\n\t\t\tif (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? \"s\" : \"\"} ago`;\n\t\t\tif (diffDays === 1) return \"1 day ago\";\n\t\t\tif (diffDays < 7) return `${diffDays} days ago`;\n\n\t\t\treturn date.toLocaleDateString();\n\t\t};\n\n\t\t// Calculate visible range with scrolling\n\t\tconst startIndex = Math.max(\n\t\t\t0,\n\t\t\tMath.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredSessions.length - this.maxVisible),\n\t\t);\n\t\tconst endIndex = Math.min(startIndex + this.maxVisible, this.filteredSessions.length);\n\n\t\t// Render visible sessions (2 lines per session + blank line)\n\t\tfor (let i = startIndex; i < endIndex; i++) {\n\t\t\tconst session = this.filteredSessions[i];\n\t\t\tconst isSelected = i === this.selectedIndex;\n\n\t\t\t// Normalize first message to single line\n\t\t\tconst normalizedMessage = session.firstMessage.replace(/\\n/g, \" \").trim();\n\n\t\t\t// First line: cursor + message (truncate to visible width)\n\t\t\tconst cursor = isSelected ? theme.fg(\"accent\", \"› \") : \" \";\n\t\t\tconst maxMsgWidth = width - 2; // Account for cursor (2 visible chars)\n\t\t\tconst truncatedMsg = truncateToWidth(normalizedMessage, maxMsgWidth, \"...\");\n\t\t\tconst messageLine = cursor + (isSelected ? theme.bold(truncatedMsg) : truncatedMsg);\n\n\t\t\t// Second line: metadata (dimmed) - also truncate for safety\n\t\t\tconst modified = formatDate(session.modified);\n\t\t\tconst msgCount = `${session.messageCount} message${session.messageCount !== 1 ? \"s\" : \"\"}`;\n\t\t\tconst metadata = ` ${modified} · ${msgCount}`;\n\t\t\tconst metadataLine = theme.fg(\"dim\", truncateToWidth(metadata, width, \"\"));\n\n\t\t\tlines.push(messageLine);\n\t\t\tlines.push(metadataLine);\n\t\t\tlines.push(\"\"); // Blank line between sessions\n\t\t}\n\n\t\t// Add scroll indicator if needed\n\t\tif (startIndex > 0 || endIndex < this.filteredSessions.length) {\n\t\t\tconst scrollText = ` (${this.selectedIndex + 1}/${this.filteredSessions.length})`;\n\t\t\tconst scrollInfo = theme.fg(\"muted\", truncateToWidth(scrollText, width, \"\"));\n\t\t\tlines.push(scrollInfo);\n\t\t}\n\n\t\treturn lines;\n\t}\n\n\thandleInput(keyData: string): void {\n\t\t// Up arrow\n\t\tif (keyData === \"\\x1b[A\") {\n\t\t\tthis.selectedIndex = Math.max(0, this.selectedIndex - 1);\n\t\t}\n\t\t// Down arrow\n\t\telse if (keyData === \"\\x1b[B\") {\n\t\t\tthis.selectedIndex = Math.min(this.filteredSessions.length - 1, this.selectedIndex + 1);\n\t\t}\n\t\t// Enter\n\t\telse if (keyData === \"\\r\") {\n\t\t\tconst selected = this.filteredSessions[this.selectedIndex];\n\t\t\tif (selected && this.onSelect) {\n\t\t\t\tthis.onSelect(selected.path);\n\t\t\t}\n\t\t}\n\t\t// Escape - cancel\n\t\telse if (keyData === \"\\x1b\") {\n\t\t\tif (this.onCancel) {\n\t\t\t\tthis.onCancel();\n\t\t\t}\n\t\t}\n\t\t// Ctrl+C - exit process\n\t\telse if (keyData === \"\\x03\") {\n\t\t\tprocess.exit(0);\n\t\t}\n\t\t// Pass everything else to search input\n\t\telse {\n\t\t\tthis.searchInput.handleInput(keyData);\n\t\t\tthis.filterSessions(this.searchInput.getValue());\n\t\t}\n\t}\n}\n\n/**\n * Component that renders a session selector\n */\nexport class SessionSelectorComponent extends Container {\n\tprivate sessionList: SessionList;\n\n\tconstructor(sessionManager: SessionManager, onSelect: (sessionPath: string) => void, onCancel: () => void) {\n\t\tsuper();\n\n\t\t// Load all sessions\n\t\tconst sessions = sessionManager.loadAllSessions();\n\n\t\t// Add header\n\t\tthis.addChild(new Spacer(1));\n\t\tthis.addChild(new Text(theme.bold(\"Resume Session\"), 1, 0));\n\t\tthis.addChild(new Spacer(1));\n\t\tthis.addChild(new DynamicBorder());\n\t\tthis.addChild(new Spacer(1));\n\n\t\t// Create session list\n\t\tthis.sessionList = new SessionList(sessions);\n\t\tthis.sessionList.onSelect = onSelect;\n\t\tthis.sessionList.onCancel = onCancel;\n\n\t\tthis.addChild(this.sessionList);\n\n\t\t// Add bottom border\n\t\tthis.addChild(new Spacer(1));\n\t\tthis.addChild(new DynamicBorder());\n\n\t\t// Auto-cancel if no sessions\n\t\tif (sessions.length === 0) {\n\t\t\tsetTimeout(() => onCancel(), 100);\n\t\t}\n\t}\n\n\tgetSessionList(): SessionList {\n\t\treturn this.sessionList;\n\t}\n}\n"]}
@@ -1,4 +1,5 @@
1
1
  import { Container, Input, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui";
2
+ import { fuzzyFilter } from "../fuzzy.js";
2
3
  import { theme } from "../theme/theme.js";
3
4
  import { DynamicBorder } from "./dynamic-border.js";
4
5
  /**
@@ -27,20 +28,7 @@ class SessionList {
27
28
  };
28
29
  }
29
30
  filterSessions(query) {
30
- if (!query.trim()) {
31
- this.filteredSessions = this.allSessions;
32
- }
33
- else {
34
- const searchTokens = query
35
- .toLowerCase()
36
- .split(/\s+/)
37
- .filter((t) => t);
38
- this.filteredSessions = this.allSessions.filter((session) => {
39
- // Search through all messages in the session
40
- const searchText = session.allMessagesText.toLowerCase();
41
- return searchTokens.every((token) => searchText.includes(token));
42
- });
43
- }
31
+ this.filteredSessions = fuzzyFilter(this.allSessions, query, (session) => session.allMessagesText);
44
32
  this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1));
45
33
  }
46
34
  invalidate() {