pi-editor-footer 0.1.0
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/AGENTS.md +19 -0
- package/CHANGELOG.md +26 -0
- package/CONTEXT.md +29 -0
- package/README.md +84 -0
- package/docs/adr/0001-tracking-editor-for-skill-descriptions.md +18 -0
- package/docs/adr/0002-own-editor-slot-port-model-info-glow.md +18 -0
- package/docs/agents/domain.md +51 -0
- package/docs/agents/issue-tracker.md +45 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/reference/pi-tui-internals.md +144 -0
- package/docs/specs/01-config.md +57 -0
- package/docs/specs/02-identity.md +20 -0
- package/docs/specs/03-border-telemetry.md +48 -0
- package/docs/specs/04-header.md +30 -0
- package/docs/specs/05-footer.md +30 -0
- package/docs/specs/06-git.md +36 -0
- package/docs/specs/07-runtime.md +28 -0
- package/docs/specs/theme-overview.md +116 -0
- package/package.json +16 -0
- package/src/config.ts +184 -0
- package/src/detail-render.ts +119 -0
- package/src/footer.ts +479 -0
- package/src/git.ts +170 -0
- package/src/header.ts +185 -0
- package/src/icons.ts +197 -0
- package/src/index.ts +607 -0
- package/src/model-info.ts +341 -0
- package/src/runtime.ts +318 -0
- package/src/state.ts +144 -0
- package/src/telemetry.ts +437 -0
- package/src/theme-settings.ts +461 -0
- package/src/tracking-editor.ts +352 -0
- package/src/utils-workspace.ts +48 -0
- package/src/utils.ts +388 -0
- package/src/window-presentation.ts +56 -0
- package/test/config.test.ts +146 -0
- package/test/detail-render.test.ts +202 -0
- package/test/footer.test.ts +86 -0
- package/test/git.test.ts +45 -0
- package/test/header.test.ts +169 -0
- package/test/icons.test.ts +24 -0
- package/test/runtime.test.ts +71 -0
- package/test/telemetry.test.ts +199 -0
- package/test/utils.test.ts +71 -0
- package/test/window-presentation.test.ts +73 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
|
|
4
|
+
import { renderDetail, scroll, type DetailItem } from "../src/detail-render";
|
|
5
|
+
|
|
6
|
+
const item: DetailItem = {
|
|
7
|
+
label: "to-spec",
|
|
8
|
+
kind: "skill",
|
|
9
|
+
description: "Turn the current conversation into a spec.",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe("renderDetail", () => {
|
|
13
|
+
test("returns [] for a null item", () => {
|
|
14
|
+
assert.deepEqual(renderDetail(null, 40, 5, 0), []);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("returns [] for an empty description", () => {
|
|
18
|
+
assert.deepEqual(renderDetail({ ...item, description: "" }, 40, 5, 0), []);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("returns [] for a whitespace-only description", () => {
|
|
22
|
+
assert.deepEqual(
|
|
23
|
+
renderDetail({ ...item, description: " \n\t " }, 40, 5, 0),
|
|
24
|
+
[],
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("wraps a long description to width, every line <= width", () => {
|
|
29
|
+
const longItem: DetailItem = {
|
|
30
|
+
...item,
|
|
31
|
+
description:
|
|
32
|
+
"This is a deliberately long description that will not fit in the window width and must therefore be wrapped across several lines.",
|
|
33
|
+
};
|
|
34
|
+
const lines = renderDetail(longItem, 20, 5, 0);
|
|
35
|
+
assert.ok(lines.length > 1);
|
|
36
|
+
for (const line of lines) {
|
|
37
|
+
assert.ok(line.length <= 20, `line too long: ${JSON.stringify(line)}`);
|
|
38
|
+
}
|
|
39
|
+
// 129 chars at width 20 → 7 content lines → overflows → marker present.
|
|
40
|
+
assert.equal(lines[0], "to-spec · skill 1/7");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("shrink-to-fit: short description yields 1 header + content lines", () => {
|
|
44
|
+
// Description is 42 chars — use a width wide enough that it stays on one line.
|
|
45
|
+
const lines = renderDetail(item, 60, 5, 0);
|
|
46
|
+
assert.deepEqual(lines, [
|
|
47
|
+
"to-spec · skill",
|
|
48
|
+
"Turn the current conversation into a spec.",
|
|
49
|
+
]);
|
|
50
|
+
assert.ok(lines.length < 5);
|
|
51
|
+
});
|
|
52
|
+
test("cap: overflowing description yields exactly maxLines lines", () => {
|
|
53
|
+
const longItem: DetailItem = {
|
|
54
|
+
...item,
|
|
55
|
+
description: "word ".repeat(60).trim(),
|
|
56
|
+
};
|
|
57
|
+
const lines = renderDetail(longItem, 20, 5, 0);
|
|
58
|
+
assert.equal(lines.length, 5);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("header carries a scroll marker only when overflowing", () => {
|
|
62
|
+
// Fits: no marker.
|
|
63
|
+
const fits = renderDetail(item, 40, 5, 0);
|
|
64
|
+
assert.equal(fits[0], "to-spec · skill");
|
|
65
|
+
|
|
66
|
+
// Overflows: marker ` offset/total` appended.
|
|
67
|
+
const longItem: DetailItem = {
|
|
68
|
+
...item,
|
|
69
|
+
description: "word ".repeat(60).trim(),
|
|
70
|
+
};
|
|
71
|
+
const overflows = renderDetail(longItem, 20, 5, 0);
|
|
72
|
+
assert.match(overflows[0], /^to-spec · skill \d+\/\d+$/);
|
|
73
|
+
assert.equal(overflows[0], "to-spec · skill 1/15");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("scrollOffset shifts which content lines are visible", () => {
|
|
77
|
+
// 160 chars at width 30 → 6 content lines with distinct wrap phases;
|
|
78
|
+
// window shows header + 4 → overflows, max scroll offset 2.
|
|
79
|
+
const overflowing: DetailItem = {
|
|
80
|
+
...item,
|
|
81
|
+
description: "abcdefghij klmnopqrst uvwxyzabcd".repeat(5),
|
|
82
|
+
};
|
|
83
|
+
const top = renderDetail(overflowing, 30, 5, 0);
|
|
84
|
+
assert.equal(top[0], "to-spec · skill 1/6");
|
|
85
|
+
|
|
86
|
+
const scrolled = renderDetail(overflowing, 30, 5, 1);
|
|
87
|
+
assert.equal(scrolled.length, 5);
|
|
88
|
+
assert.equal(scrolled[0], "to-spec · skill 2/6");
|
|
89
|
+
// Scrolled lines must differ from the top lines.
|
|
90
|
+
assert.notDeepEqual(scrolled.slice(1), top.slice(1));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("clamps scrollOffset above the maximum", () => {
|
|
94
|
+
const longItem: DetailItem = {
|
|
95
|
+
...item,
|
|
96
|
+
description: "x".repeat(200),
|
|
97
|
+
};
|
|
98
|
+
// 200 chars at width 20 → 10 content lines; window shows 4 content lines → max offset 6.
|
|
99
|
+
const lines = renderDetail(longItem, 20, 5, 999);
|
|
100
|
+
assert.equal(lines.length, 5);
|
|
101
|
+
assert.equal(lines[0], "to-spec · skill 7/10");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("clamps negative scrollOffset to 0", () => {
|
|
105
|
+
const longItem: DetailItem = {
|
|
106
|
+
...item,
|
|
107
|
+
description: "x".repeat(200),
|
|
108
|
+
};
|
|
109
|
+
const lines = renderDetail(longItem, 20, 5, -5);
|
|
110
|
+
assert.equal(lines.length, 5);
|
|
111
|
+
assert.equal(lines[0], "to-spec · skill 1/10");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("preserves explicit paragraph breaks in the description", () => {
|
|
115
|
+
const multiline: DetailItem = {
|
|
116
|
+
...item,
|
|
117
|
+
description: "First paragraph.\n\nSecond paragraph.",
|
|
118
|
+
};
|
|
119
|
+
const lines = renderDetail(multiline, 40, 5, 0);
|
|
120
|
+
assert.deepEqual(lines, [
|
|
121
|
+
"to-spec · skill",
|
|
122
|
+
"First paragraph.",
|
|
123
|
+
"",
|
|
124
|
+
"Second paragraph.",
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("header never exceeds width and the scroll marker survives truncation", () => {
|
|
129
|
+
const overflowing: DetailItem = {
|
|
130
|
+
...item,
|
|
131
|
+
description: "x".repeat(200),
|
|
132
|
+
};
|
|
133
|
+
const lines = renderDetail(overflowing, 10, 5, 0);
|
|
134
|
+
assert.ok(lines[0].length <= 10);
|
|
135
|
+
assert.match(lines[0], /\d+\/\d+$/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("shows '...' on the last visible line when content remains below", () => {
|
|
139
|
+
const longItem: DetailItem = {
|
|
140
|
+
...item,
|
|
141
|
+
description: "x".repeat(200), // 10 content lines at width 20
|
|
142
|
+
};
|
|
143
|
+
const lines = renderDetail(longItem, 20, 5, 0);
|
|
144
|
+
assert.equal(lines[4], "...");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("no bottom ellipsis when scrolled to the bottom", () => {
|
|
148
|
+
const longItem: DetailItem = {
|
|
149
|
+
...item,
|
|
150
|
+
description: "x".repeat(200),
|
|
151
|
+
};
|
|
152
|
+
const lines = renderDetail(longItem, 20, 5, 999); // clamped to max offset 6
|
|
153
|
+
assert.equal(lines.length, 5);
|
|
154
|
+
assert.notEqual(lines[4], "...");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("mid-scroll: only the bottom ellipsis, first visible line stays content", () => {
|
|
158
|
+
const longItem: DetailItem = {
|
|
159
|
+
...item,
|
|
160
|
+
description: "x".repeat(200),
|
|
161
|
+
};
|
|
162
|
+
const mid = renderDetail(longItem, 20, 5, 3);
|
|
163
|
+
assert.notEqual(mid[1], "..."); // no top ellipsis
|
|
164
|
+
assert.equal(mid[4], "..."); // content still remains below
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("no ellipsis when the description fits the window", () => {
|
|
168
|
+
const lines = renderDetail(item, 60, 5, 0);
|
|
169
|
+
assert.deepEqual(lines, [
|
|
170
|
+
"to-spec · skill",
|
|
171
|
+
"Turn the current conversation into a spec.",
|
|
172
|
+
]);
|
|
173
|
+
assert.ok(!lines.includes("..."));
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("scroll", () => {
|
|
178
|
+
test("returns 0 when the description fits the window", () => {
|
|
179
|
+
assert.equal(scroll(0, 1, 2, 5), 0);
|
|
180
|
+
assert.equal(scroll(3, -1, 2, 5), 0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("advances one line per delta within bounds", () => {
|
|
184
|
+
// 10 content lines, window shows 4 → max offset 6.
|
|
185
|
+
assert.equal(scroll(0, 1, 10, 5), 1);
|
|
186
|
+
assert.equal(scroll(3, 1, 10, 5), 4);
|
|
187
|
+
assert.equal(scroll(4, -1, 10, 5), 3);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("clamps at the bottom (forward)", () => {
|
|
191
|
+
assert.equal(scroll(6, 1, 10, 5), 6);
|
|
192
|
+
assert.equal(scroll(999, 1, 10, 5), 6);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("clamps at the top (back)", () => {
|
|
196
|
+
assert.equal(scroll(0, -1, 10, 5), 0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("edge: window of 1 line means no content scrollable", () => {
|
|
200
|
+
assert.equal(scroll(0, 1, 10, 1), 0);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { renderFooter } from "../src/footer.js";
|
|
4
|
+
import { createInitialState } from "../src/state.js";
|
|
5
|
+
import { DEFAULT_CONFIG } from "../src/config.js";
|
|
6
|
+
import { emptyGitStatus } from "../src/git.js";
|
|
7
|
+
|
|
8
|
+
const theme = {
|
|
9
|
+
fg: (_style: string, text: string) => text,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function makeState(
|
|
13
|
+
overrides: Partial<ReturnType<typeof createInitialState>> = {},
|
|
14
|
+
) {
|
|
15
|
+
return { ...createInitialState(), ...overrides };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("footer", () => {
|
|
19
|
+
test("renders one line at normal width", () => {
|
|
20
|
+
const state = makeState();
|
|
21
|
+
const lines = renderFooter(80, state, DEFAULT_CONFIG, theme as never, {
|
|
22
|
+
cwd: "/Users/test/project",
|
|
23
|
+
});
|
|
24
|
+
assert.equal(lines.length, 1);
|
|
25
|
+
assert.ok(lines[0]!.length > 0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("sheds segments at narrow width", () => {
|
|
29
|
+
const state = makeState({
|
|
30
|
+
git: {
|
|
31
|
+
...emptyGitStatus(),
|
|
32
|
+
branch: "feature/very-long-branch-name-that-exceeds",
|
|
33
|
+
staged: 2,
|
|
34
|
+
modified: 3,
|
|
35
|
+
},
|
|
36
|
+
runtime: { name: "nodejs", version: "20.0.0" },
|
|
37
|
+
});
|
|
38
|
+
const narrow = renderFooter(30, state, DEFAULT_CONFIG, theme as never, {
|
|
39
|
+
cwd: "/Users/test/project",
|
|
40
|
+
});
|
|
41
|
+
assert.equal(narrow.length, 1);
|
|
42
|
+
// Should not exceed width (visible width approximated without ANSI)
|
|
43
|
+
for (const line of narrow) {
|
|
44
|
+
assert.ok(line.length <= 80);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("workspaceDisplay switches cwd format (path vs name)", () => {
|
|
49
|
+
const state = makeState();
|
|
50
|
+
const configPath = { ...DEFAULT_CONFIG, workspaceDisplay: "path" as const };
|
|
51
|
+
const configName = { ...DEFAULT_CONFIG, workspaceDisplay: "name" as const };
|
|
52
|
+
const linesPath = renderFooter(80, state, configPath, theme as never, {
|
|
53
|
+
cwd: "/Users/test/long/path/to/project",
|
|
54
|
+
});
|
|
55
|
+
const linesName = renderFooter(80, state, configName, theme as never, {
|
|
56
|
+
cwd: "/Users/test/long/path/to/project",
|
|
57
|
+
});
|
|
58
|
+
assert.ok(linesPath[0]!.includes("/Users/test/long/path/to/project") || linesPath[0]!.includes("long/path/to/project"));
|
|
59
|
+
assert.ok(linesName[0]!.includes("project"));
|
|
60
|
+
assert.ok(!linesName[0]!.includes("/Users/test/long/path/to/project"));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("respects footerSegments toggles", () => {
|
|
64
|
+
const state = makeState({ git: { ...emptyGitStatus(), branch: "main" } });
|
|
65
|
+
const config = {
|
|
66
|
+
...DEFAULT_CONFIG,
|
|
67
|
+
footerSegments: {
|
|
68
|
+
...DEFAULT_CONFIG.footerSegments,
|
|
69
|
+
gitBranch: false,
|
|
70
|
+
gitStatus: false,
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
const lines = renderFooter(80, state, config, theme as never, {
|
|
74
|
+
cwd: "/tmp",
|
|
75
|
+
});
|
|
76
|
+
assert.ok(!lines[0]!.includes("main"));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("handles zero width", () => {
|
|
80
|
+
const state = makeState();
|
|
81
|
+
const lines = renderFooter(0, state, DEFAULT_CONFIG, theme as never, {
|
|
82
|
+
cwd: "/tmp",
|
|
83
|
+
});
|
|
84
|
+
assert.equal(lines[0], "");
|
|
85
|
+
});
|
|
86
|
+
});
|
package/test/git.test.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { emptyGitStatus, hasGitChanges, readGitStatus } from "../src/git.js";
|
|
7
|
+
|
|
8
|
+
describe("git", () => {
|
|
9
|
+
test("emptyGitStatus has defaults", () => {
|
|
10
|
+
const s = emptyGitStatus();
|
|
11
|
+
assert.equal(s.branch, undefined);
|
|
12
|
+
assert.equal(s.ahead, 0);
|
|
13
|
+
assert.equal(s.staged, 0);
|
|
14
|
+
assert.equal(s.commit, null);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("hasGitChanges false when clean", () => {
|
|
18
|
+
assert.equal(hasGitChanges(emptyGitStatus()), false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("hasGitChanges true when modified", () => {
|
|
22
|
+
const s = emptyGitStatus();
|
|
23
|
+
s.modified = 1;
|
|
24
|
+
assert.equal(hasGitChanges(s), true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("readGitStatus on non-git dir returns empty", async () => {
|
|
28
|
+
const dir = mkdtempSync(join(tmpdir(), "git-test-"));
|
|
29
|
+
try {
|
|
30
|
+
const status = await readGitStatus(dir);
|
|
31
|
+
assert.equal(status.branch, undefined);
|
|
32
|
+
assert.equal(status.staged, 0);
|
|
33
|
+
} finally {
|
|
34
|
+
rmSync(dir, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("readGitStatus on this repo returns branch", async () => {
|
|
39
|
+
// Use the worktree root, which is a git repo
|
|
40
|
+
const status = await readGitStatus(process.cwd());
|
|
41
|
+
// Branch may be undefined if detached, but should not throw
|
|
42
|
+
assert.ok(typeof status.ahead === "number");
|
|
43
|
+
assert.ok(typeof status.staged === "number");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import {
|
|
4
|
+
basenamePath,
|
|
5
|
+
formatCwd,
|
|
6
|
+
renderHeader,
|
|
7
|
+
truncatePath,
|
|
8
|
+
type HeaderThemeLike,
|
|
9
|
+
} from "../src/header.js";
|
|
10
|
+
|
|
11
|
+
const theme: HeaderThemeLike = {
|
|
12
|
+
fg: (_style: string, text: string) => text,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe("formatCwd", () => {
|
|
16
|
+
it("leaves cwd outside home untouched", () => {
|
|
17
|
+
const cwd = "/tmp/other/path";
|
|
18
|
+
// HOME may vary; at least ensure it doesn't throw
|
|
19
|
+
assert.equal(typeof formatCwd(cwd), "string");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("collapses home to ~", () => {
|
|
23
|
+
const home = process.env.HOME ?? "";
|
|
24
|
+
if (!home) return;
|
|
25
|
+
assert.equal(formatCwd(home), "~");
|
|
26
|
+
assert.equal(formatCwd(`${home}/projects/foo`), `~/projects/foo`);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("basenamePath", () => {
|
|
31
|
+
it("extracts basename", () => {
|
|
32
|
+
assert.equal(basenamePath("~/a/b/c"), "c");
|
|
33
|
+
assert.equal(basenamePath("/foo/bar"), "bar");
|
|
34
|
+
assert.equal(basenamePath("single"), "single");
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("truncatePath", () => {
|
|
39
|
+
it("leaves short paths untouched", () => {
|
|
40
|
+
assert.equal(truncatePath("~/a/b", 10), "~/a/b");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("truncates long paths with middle ellipsis", () => {
|
|
44
|
+
const long = "~/very/long/path/with/many/segments/here";
|
|
45
|
+
const out = truncatePath(long, 16);
|
|
46
|
+
assert.ok(out.includes("..."));
|
|
47
|
+
assert.ok(out.length <= 16);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("handles maxLen <=3", () => {
|
|
51
|
+
assert.equal(truncatePath("abcdef", 2), "..");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("renderHeader", () => {
|
|
56
|
+
it("shows cwd with icon and hints at wide width", () => {
|
|
57
|
+
const lines = renderHeader(
|
|
58
|
+
80,
|
|
59
|
+
{
|
|
60
|
+
cwd: "/tmp/my-workspace",
|
|
61
|
+
workspaceDisplay: "path",
|
|
62
|
+
tipCommands: ["theme", "model-info"],
|
|
63
|
+
},
|
|
64
|
+
theme,
|
|
65
|
+
);
|
|
66
|
+
assert.equal(lines.length, 1);
|
|
67
|
+
assert.ok(lines[0]!.includes("my-workspace"));
|
|
68
|
+
assert.ok(lines[0]!.includes("/theme"));
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("honours workspaceDisplay=name (basename only)", () => {
|
|
72
|
+
const wide = renderHeader(
|
|
73
|
+
80,
|
|
74
|
+
{
|
|
75
|
+
cwd: "/home/user/projects/my-workspace",
|
|
76
|
+
workspaceDisplay: "name",
|
|
77
|
+
tipCommands: ["theme"],
|
|
78
|
+
},
|
|
79
|
+
theme,
|
|
80
|
+
);
|
|
81
|
+
const pathMode = renderHeader(
|
|
82
|
+
80,
|
|
83
|
+
{
|
|
84
|
+
cwd: "/home/user/projects/my-workspace",
|
|
85
|
+
workspaceDisplay: "path",
|
|
86
|
+
tipCommands: ["theme"],
|
|
87
|
+
},
|
|
88
|
+
theme,
|
|
89
|
+
);
|
|
90
|
+
assert.ok(wide[0]!.includes("my-workspace"));
|
|
91
|
+
// path mode should contain more than just basename (either ~ or full)
|
|
92
|
+
assert.ok(pathMode[0]!.length >= wide[0]!.length);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("drops tips at narrow width", () => {
|
|
96
|
+
const lines = renderHeader(
|
|
97
|
+
20,
|
|
98
|
+
{
|
|
99
|
+
cwd: "/tmp/ws",
|
|
100
|
+
workspaceDisplay: "path",
|
|
101
|
+
tipCommands: ["theme", "model-info"],
|
|
102
|
+
},
|
|
103
|
+
theme,
|
|
104
|
+
);
|
|
105
|
+
assert.equal(lines.length, 1);
|
|
106
|
+
// Should still contain cwd but not necessarily tips (depends on logic)
|
|
107
|
+
assert.ok(lines[0]!.includes("ws"));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns empty for missing cwd", () => {
|
|
111
|
+
assert.deepEqual(
|
|
112
|
+
renderHeader(
|
|
113
|
+
80,
|
|
114
|
+
{ cwd: "", workspaceDisplay: "path", tipCommands: ["theme"] },
|
|
115
|
+
theme,
|
|
116
|
+
),
|
|
117
|
+
[],
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("returns empty for zero width", () => {
|
|
122
|
+
assert.deepEqual(
|
|
123
|
+
renderHeader(
|
|
124
|
+
0,
|
|
125
|
+
{ cwd: "/tmp/x", workspaceDisplay: "path", tipCommands: ["x"] },
|
|
126
|
+
theme,
|
|
127
|
+
),
|
|
128
|
+
[],
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("truncates long cwd via truncatePath", () => {
|
|
133
|
+
const lines = renderHeader(
|
|
134
|
+
40,
|
|
135
|
+
{
|
|
136
|
+
cwd: "/very/long/path/that/needs/truncation/because/it/is/huge/workspace",
|
|
137
|
+
workspaceDisplay: "path",
|
|
138
|
+
tipCommands: ["theme"],
|
|
139
|
+
},
|
|
140
|
+
theme,
|
|
141
|
+
);
|
|
142
|
+
assert.ok(lines[0]!.length <= 40);
|
|
143
|
+
// When truncation happens, should contain ...
|
|
144
|
+
if (lines[0]!.length === 40) assert.ok(lines[0]!.includes("..."));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("normalizes tipCommands without leading slash", () => {
|
|
148
|
+
const lines = renderHeader(
|
|
149
|
+
80,
|
|
150
|
+
{ cwd: "/tmp/ws", workspaceDisplay: "path", tipCommands: ["theme"] },
|
|
151
|
+
theme,
|
|
152
|
+
);
|
|
153
|
+
assert.ok(lines[0]!.includes("/theme"));
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("respects custom cwd icon", () => {
|
|
157
|
+
const lines = renderHeader(
|
|
158
|
+
80,
|
|
159
|
+
{
|
|
160
|
+
cwd: "/tmp/ws",
|
|
161
|
+
workspaceDisplay: "path",
|
|
162
|
+
tipCommands: [],
|
|
163
|
+
iconCwd: "",
|
|
164
|
+
},
|
|
165
|
+
theme,
|
|
166
|
+
);
|
|
167
|
+
assert.ok(lines[0]!.includes(""));
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { resolveGlyphs, resolveIconMode, runtimeSymbol } from "../src/icons.js";
|
|
4
|
+
|
|
5
|
+
describe("icons", () => {
|
|
6
|
+
test("resolveIconMode respects explicit modes", () => {
|
|
7
|
+
assert.equal(resolveIconMode("nerd"), "nerd");
|
|
8
|
+
assert.equal(resolveIconMode("ascii"), "ascii");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("resolveGlyphs returns glyphs", () => {
|
|
12
|
+
const nerd = resolveGlyphs("nerd");
|
|
13
|
+
const ascii = resolveGlyphs("ascii");
|
|
14
|
+
assert.ok(nerd.git);
|
|
15
|
+
assert.ok(ascii.git);
|
|
16
|
+
assert.notEqual(nerd.git, ascii.git);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("runtimeSymbol returns symbols", () => {
|
|
20
|
+
assert.equal(runtimeSymbol("nodejs", "ascii"), "node");
|
|
21
|
+
assert.ok(runtimeSymbol("nodejs", "nerd").length > 0);
|
|
22
|
+
assert.equal(runtimeSymbol("unknown-xyz", "ascii"), "unknown-xyz");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { clearRuntimeCache, readRuntimeInfo } from "../src/runtime.js";
|
|
7
|
+
|
|
8
|
+
describe("runtime", () => {
|
|
9
|
+
test("detects nodejs via package.json", async () => {
|
|
10
|
+
const dir = mkdtempSync(join(tmpdir(), "runtime-node-"));
|
|
11
|
+
try {
|
|
12
|
+
writeFileSync(join(dir, "package.json"), "{}");
|
|
13
|
+
clearRuntimeCache();
|
|
14
|
+
const info = await readRuntimeInfo(dir);
|
|
15
|
+
assert.ok(info);
|
|
16
|
+
assert.equal(info!.name, "nodejs");
|
|
17
|
+
} finally {
|
|
18
|
+
rmSync(dir, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("detects python via pyproject.toml", async () => {
|
|
23
|
+
const dir = mkdtempSync(join(tmpdir(), "runtime-py-"));
|
|
24
|
+
try {
|
|
25
|
+
writeFileSync(join(dir, "pyproject.toml"), "[tool.poetry]");
|
|
26
|
+
clearRuntimeCache();
|
|
27
|
+
const info = await readRuntimeInfo(dir);
|
|
28
|
+
assert.ok(info);
|
|
29
|
+
assert.equal(info!.name, "python");
|
|
30
|
+
} finally {
|
|
31
|
+
rmSync(dir, { recursive: true, force: true });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("detects rust via Cargo.toml", async () => {
|
|
36
|
+
const dir = mkdtempSync(join(tmpdir(), "runtime-rust-"));
|
|
37
|
+
try {
|
|
38
|
+
writeFileSync(join(dir, "Cargo.toml"), "[package]");
|
|
39
|
+
clearRuntimeCache();
|
|
40
|
+
const info = await readRuntimeInfo(dir);
|
|
41
|
+
assert.ok(info);
|
|
42
|
+
assert.equal(info!.name, "rust");
|
|
43
|
+
} finally {
|
|
44
|
+
rmSync(dir, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("returns null for empty dir", async () => {
|
|
49
|
+
const dir = mkdtempSync(join(tmpdir(), "runtime-empty-"));
|
|
50
|
+
try {
|
|
51
|
+
clearRuntimeCache();
|
|
52
|
+
const info = await readRuntimeInfo(dir);
|
|
53
|
+
assert.equal(info, null);
|
|
54
|
+
} finally {
|
|
55
|
+
rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("cache returns same on repeat", async () => {
|
|
60
|
+
const dir = mkdtempSync(join(tmpdir(), "runtime-cache-"));
|
|
61
|
+
try {
|
|
62
|
+
writeFileSync(join(dir, "go.mod"), "module example");
|
|
63
|
+
clearRuntimeCache();
|
|
64
|
+
const a = await readRuntimeInfo(dir);
|
|
65
|
+
const b = await readRuntimeInfo(dir);
|
|
66
|
+
assert.ok(a && b && a.name === b.name);
|
|
67
|
+
} finally {
|
|
68
|
+
rmSync(dir, { recursive: true, force: true });
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|