@dungle-scrubs/tallow 0.8.27 → 0.8.28
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/README.md +42 -1
- package/dist/cli.js +7 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/model-metadata-overrides.d.ts +2 -5
- package/dist/model-metadata-overrides.d.ts.map +1 -1
- package/dist/model-metadata-overrides.js +23 -12
- package/dist/model-metadata-overrides.js.map +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +20 -9
- package/dist/sdk.js.map +1 -1
- package/dist/workspace-transition-interactive.d.ts.map +1 -1
- package/dist/workspace-transition-interactive.js +53 -3
- package/dist/workspace-transition-interactive.js.map +1 -1
- package/dist/workspace-transition.d.ts +2 -1
- package/dist/workspace-transition.d.ts.map +1 -1
- package/dist/workspace-transition.js +16 -4
- package/dist/workspace-transition.js.map +1 -1
- package/extensions/__integration__/cd-tool-guidelines.test.ts +46 -0
- package/extensions/__integration__/welcome-screen.test.ts +240 -0
- package/extensions/_shared/pid-registry.ts +5 -5
- package/extensions/background-task-tool/index.ts +1 -1
- package/extensions/cd-tool/index.ts +4 -1
- package/extensions/edit-tool-enhanced/index.ts +3 -1
- package/extensions/health/__tests__/diagnostics.test.ts +25 -0
- package/extensions/health/index.ts +61 -0
- package/extensions/loop/__tests__/loop.test.ts +365 -1
- package/extensions/loop/index.ts +213 -3
- package/extensions/prompt-suggestions/__tests__/autocomplete.test.ts +111 -3
- package/extensions/prompt-suggestions/autocomplete.ts +23 -5
- package/extensions/prompt-suggestions/index.ts +62 -3
- package/extensions/read-tool-enhanced/index.ts +5 -1
- package/extensions/session-memory/index.ts +1 -1
- package/extensions/session-namer/index.ts +1 -1
- package/extensions/subagent-tool/__tests__/presentation-rendering.test.ts +4 -4
- package/extensions/subagent-tool/index.ts +4 -2
- package/extensions/subagent-tool/process.ts +26 -8
- package/extensions/teams-tool/sessions/spawn.ts +2 -2
- package/extensions/welcome-screen/__tests__/welcome-screen.test.ts +35 -0
- package/extensions/welcome-screen/extension.json +20 -0
- package/extensions/welcome-screen/index.ts +189 -0
- package/node_modules/@mariozechner/pi-tui/dist/index.d.ts +2 -2
- package/node_modules/@mariozechner/pi-tui/dist/index.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/index.js +2 -2
- package/node_modules/@mariozechner/pi-tui/dist/index.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts +309 -25
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.js +392 -72
- package/node_modules/@mariozechner/pi-tui/dist/keybindings.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts +30 -0
- package/node_modules/@mariozechner/pi-tui/dist/keys.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/keys.js +50 -6
- package/node_modules/@mariozechner/pi-tui/dist/keys.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts +27 -0
- package/node_modules/@mariozechner/pi-tui/dist/terminal.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/terminal.js +59 -4
- package/node_modules/@mariozechner/pi-tui/dist/terminal.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts +9 -0
- package/node_modules/@mariozechner/pi-tui/dist/tui.d.ts.map +1 -1
- package/node_modules/@mariozechner/pi-tui/dist/tui.js +50 -1
- package/node_modules/@mariozechner/pi-tui/dist/tui.js.map +1 -1
- package/node_modules/@mariozechner/pi-tui/package.json +1 -1
- package/node_modules/@mariozechner/pi-tui/src/__tests__/mouse-events.test.ts +134 -0
- package/node_modules/@mariozechner/pi-tui/src/__tests__/tmux-compat.test.ts +204 -0
- package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-diff-regression.test.ts +49 -0
- package/node_modules/@mariozechner/pi-tui/src/__tests__/tui-render-scheduling.test.ts +2 -0
- package/node_modules/@mariozechner/pi-tui/src/index.ts +11 -0
- package/node_modules/@mariozechner/pi-tui/src/keybindings.ts +478 -140
- package/node_modules/@mariozechner/pi-tui/src/keys.ts +84 -6
- package/node_modules/@mariozechner/pi-tui/src/terminal.ts +69 -4
- package/node_modules/@mariozechner/pi-tui/src/tui.ts +64 -1
- package/package.json +9 -9
- package/runtime/config.ts +7 -0
- package/runtime/model-metadata-overrides.ts +7 -0
- package/skills/tallow-expert/SKILL.md +6 -4
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2E integration test for the welcome-screen extension.
|
|
3
|
+
*
|
|
4
|
+
* Boots a real tallow session with the bundled welcome-screen extension,
|
|
5
|
+
* binds extensions with a mock UI, and verifies:
|
|
6
|
+
* - setHeader IS called on fresh sessions
|
|
7
|
+
* - The rendered output contains the ASCII logo and version
|
|
8
|
+
* - setHeader is NOT called on resumed sessions with conversation history
|
|
9
|
+
*/
|
|
10
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { visibleWidth } from "@mariozechner/pi-tui";
|
|
15
|
+
import { TALLOW_VERSION } from "../../src/config.js";
|
|
16
|
+
import { createTallowSession, type TallowSession } from "../../src/sdk.js";
|
|
17
|
+
import { withExclusiveTallowHome } from "../../test-utils/tallow-home-env.js";
|
|
18
|
+
import welcomeScreenExtension from "../welcome-screen/index.js";
|
|
19
|
+
|
|
20
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/** Lines the ASCII logo must contain (stripped of ANSI). */
|
|
23
|
+
const LOGO_FRAGMENTS = ["▐████████████▌", "████", "▐█▌"];
|
|
24
|
+
|
|
25
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape stripping requires matching \x1b
|
|
26
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
27
|
+
|
|
28
|
+
/** Strip ANSI escape sequences for content assertions. */
|
|
29
|
+
function stripAnsi(s: string): string {
|
|
30
|
+
return s.replace(ANSI_RE, "");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create a session with only the welcome-screen extension loaded.
|
|
35
|
+
*
|
|
36
|
+
* @param cwd - Working directory for the session
|
|
37
|
+
* @returns TallowSession promise
|
|
38
|
+
*/
|
|
39
|
+
function createWelcomeSession(cwd: string): Promise<TallowSession> {
|
|
40
|
+
return withExclusiveTallowHome(cwd, () =>
|
|
41
|
+
createTallowSession({
|
|
42
|
+
cwd,
|
|
43
|
+
provider: "anthropic",
|
|
44
|
+
apiKey: "test-key",
|
|
45
|
+
session: { type: "memory" },
|
|
46
|
+
noBundledExtensions: true,
|
|
47
|
+
noBundledSkills: true,
|
|
48
|
+
extensionFactories: [welcomeScreenExtension],
|
|
49
|
+
})
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build a mock UI context that captures setHeader calls.
|
|
55
|
+
*
|
|
56
|
+
* @returns Object with the UI context and captured state
|
|
57
|
+
*/
|
|
58
|
+
function createCapturingUI(): {
|
|
59
|
+
uiContext: Record<string, unknown>;
|
|
60
|
+
capture: {
|
|
61
|
+
setHeaderCalled: boolean;
|
|
62
|
+
headerFactory: ((tui: unknown, theme: unknown) => { render(w: number): string[] }) | null;
|
|
63
|
+
};
|
|
64
|
+
} {
|
|
65
|
+
const capture = {
|
|
66
|
+
setHeaderCalled: false,
|
|
67
|
+
headerFactory: null as
|
|
68
|
+
| ((tui: unknown, theme: unknown) => { render(w: number): string[] })
|
|
69
|
+
| null,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const uiContext: Record<string, unknown> = {
|
|
73
|
+
notify: () => {},
|
|
74
|
+
confirm: async () => true,
|
|
75
|
+
input: async () => null,
|
|
76
|
+
select: async () => null,
|
|
77
|
+
custom: async () => null,
|
|
78
|
+
setWorkingMessage: () => {},
|
|
79
|
+
setHeader: (factory: typeof capture.headerFactory) => {
|
|
80
|
+
capture.setHeaderCalled = true;
|
|
81
|
+
capture.headerFactory = factory;
|
|
82
|
+
},
|
|
83
|
+
setFooter: () => {},
|
|
84
|
+
setToolsExpanded: () => {},
|
|
85
|
+
setEditorComponent: () => {},
|
|
86
|
+
addTerminalInputListener: () => () => {},
|
|
87
|
+
setStatus: () => {},
|
|
88
|
+
setWidget: () => {},
|
|
89
|
+
setTitle: () => {},
|
|
90
|
+
pasteToEditor: () => {},
|
|
91
|
+
setEditorText: () => {},
|
|
92
|
+
getEditorText: () => "",
|
|
93
|
+
editor: async () => undefined,
|
|
94
|
+
hasUI: true,
|
|
95
|
+
};
|
|
96
|
+
return { uiContext, capture };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let tmpDir: string | undefined;
|
|
100
|
+
let session: TallowSession | undefined;
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
if (tmpDir) {
|
|
104
|
+
try {
|
|
105
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
106
|
+
} catch {
|
|
107
|
+
// best-effort
|
|
108
|
+
}
|
|
109
|
+
tmpDir = undefined;
|
|
110
|
+
}
|
|
111
|
+
session = undefined;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
describe("welcome-screen E2E", () => {
|
|
117
|
+
it("calls setHeader with the ASCII logo on a fresh session", async () => {
|
|
118
|
+
tmpDir = mkdtempSync(join(tmpdir(), "tallow-welcome-e2e-"));
|
|
119
|
+
session = await createWelcomeSession(tmpDir);
|
|
120
|
+
|
|
121
|
+
const { uiContext, capture } = createCapturingUI();
|
|
122
|
+
await session.session.bindExtensions({ uiContext });
|
|
123
|
+
|
|
124
|
+
// ── setHeader must have been invoked ──
|
|
125
|
+
expect(capture.setHeaderCalled).toBe(true);
|
|
126
|
+
expect(capture.headerFactory).not.toBeNull();
|
|
127
|
+
|
|
128
|
+
// ── Render the header and validate content ──
|
|
129
|
+
const component = capture.headerFactory?.(null, null);
|
|
130
|
+
expect(component).toBeDefined();
|
|
131
|
+
expect(typeof component?.render).toBe("function");
|
|
132
|
+
|
|
133
|
+
const lines = component?.render(80) ?? [];
|
|
134
|
+
const plainLines = lines.map(stripAnsi);
|
|
135
|
+
const joined = plainLines.join("\n");
|
|
136
|
+
|
|
137
|
+
// Logo fragments must be present
|
|
138
|
+
for (const frag of LOGO_FRAGMENTS) {
|
|
139
|
+
expect(joined).toContain(frag);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Version string must be present
|
|
143
|
+
expect(joined).toContain(`tallow v${TALLOW_VERSION}`);
|
|
144
|
+
}, 30_000);
|
|
145
|
+
|
|
146
|
+
it("renders lines centered within the given width", async () => {
|
|
147
|
+
tmpDir = mkdtempSync(join(tmpdir(), "tallow-welcome-e2e-"));
|
|
148
|
+
session = await createWelcomeSession(tmpDir);
|
|
149
|
+
|
|
150
|
+
const { uiContext, capture } = createCapturingUI();
|
|
151
|
+
await session.session.bindExtensions({ uiContext });
|
|
152
|
+
|
|
153
|
+
const component = capture.headerFactory?.(null, null);
|
|
154
|
+
expect(component).toBeDefined();
|
|
155
|
+
|
|
156
|
+
const width = 120;
|
|
157
|
+
const lines = component?.render(width) ?? [];
|
|
158
|
+
|
|
159
|
+
// Every line should fit within the width
|
|
160
|
+
for (const line of lines) {
|
|
161
|
+
expect(visibleWidth(line)).toBeLessThanOrEqual(width);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Logo lines should be approximately centered (left padding > 0)
|
|
165
|
+
const logoLine = lines[0]; // first line = logo top bar
|
|
166
|
+
const leading = logoLine.length - logoLine.trimStart().length;
|
|
167
|
+
expect(leading).toBeGreaterThan(0);
|
|
168
|
+
}, 30_000);
|
|
169
|
+
|
|
170
|
+
it("does NOT call setHeader on a resumed session with conversation entries", async () => {
|
|
171
|
+
tmpDir = mkdtempSync(join(tmpdir(), "tallow-welcome-e2e-"));
|
|
172
|
+
session = await createWelcomeSession(tmpDir);
|
|
173
|
+
|
|
174
|
+
// Inject conversation entries to simulate a resumed session
|
|
175
|
+
const sm = session.session.sessionManager;
|
|
176
|
+
sm.appendMessage({
|
|
177
|
+
role: "user",
|
|
178
|
+
content: [{ type: "text", text: "hello" }],
|
|
179
|
+
});
|
|
180
|
+
sm.appendMessage({
|
|
181
|
+
role: "assistant",
|
|
182
|
+
content: [{ type: "text", text: "hi" }],
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const { uiContext, capture } = createCapturingUI();
|
|
186
|
+
await session.session.bindExtensions({ uiContext });
|
|
187
|
+
|
|
188
|
+
// setHeader must NOT be called for resumed sessions
|
|
189
|
+
expect(capture.setHeaderCalled).toBe(false);
|
|
190
|
+
}, 30_000);
|
|
191
|
+
|
|
192
|
+
it("does NOT skip fresh sessions that only have metadata entries", async () => {
|
|
193
|
+
tmpDir = mkdtempSync(join(tmpdir(), "tallow-welcome-e2e-"));
|
|
194
|
+
session = await createWelcomeSession(tmpDir);
|
|
195
|
+
|
|
196
|
+
// Verify metadata entries exist but don't prevent the welcome screen
|
|
197
|
+
const entries = session.session.sessionManager.getEntries();
|
|
198
|
+
const metadataOnly = entries.every(
|
|
199
|
+
(e) => !("role" in e) || !["user", "assistant"].includes(String(e.role))
|
|
200
|
+
);
|
|
201
|
+
expect(metadataOnly).toBe(true);
|
|
202
|
+
expect(entries.length).toBeGreaterThan(0); // model_change, thinking_level_change
|
|
203
|
+
|
|
204
|
+
const { uiContext, capture } = createCapturingUI();
|
|
205
|
+
await session.session.bindExtensions({ uiContext });
|
|
206
|
+
|
|
207
|
+
// setHeader MUST be called even though metadata entries exist
|
|
208
|
+
expect(capture.setHeaderCalled).toBe(true);
|
|
209
|
+
}, 30_000);
|
|
210
|
+
|
|
211
|
+
it("defaults quietStartup to true so resource listing is suppressed", async () => {
|
|
212
|
+
tmpDir = mkdtempSync(join(tmpdir(), "tallow-welcome-e2e-"));
|
|
213
|
+
session = await createWelcomeSession(tmpDir);
|
|
214
|
+
|
|
215
|
+
// The settingsManager should have quietStartup=true by default,
|
|
216
|
+
// which suppresses the keybinding hints and [Context]/[Skills] listing.
|
|
217
|
+
const quiet = session.session.settingsManager.getQuietStartup();
|
|
218
|
+
expect(quiet).toBe(true);
|
|
219
|
+
}, 30_000);
|
|
220
|
+
|
|
221
|
+
it("respects explicit quietStartup=false override", async () => {
|
|
222
|
+
tmpDir = mkdtempSync(join(tmpdir(), "tallow-welcome-e2e-"));
|
|
223
|
+
session = await withExclusiveTallowHome(tmpDir, () =>
|
|
224
|
+
createTallowSession({
|
|
225
|
+
cwd: tmpDir ?? tmpdir(),
|
|
226
|
+
provider: "anthropic",
|
|
227
|
+
apiKey: "test-key",
|
|
228
|
+
session: { type: "memory" },
|
|
229
|
+
noBundledExtensions: true,
|
|
230
|
+
noBundledSkills: true,
|
|
231
|
+
extensionFactories: [welcomeScreenExtension],
|
|
232
|
+
settings: { quietStartup: false },
|
|
233
|
+
})
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
// User explicitly opted out — resource listing should be visible
|
|
237
|
+
const quiet = session.session.settingsManager.getQuietStartup();
|
|
238
|
+
expect(quiet).toBe(false);
|
|
239
|
+
}, 30_000);
|
|
240
|
+
});
|
|
@@ -12,10 +12,6 @@
|
|
|
12
12
|
import { spawnSync } from "node:child_process";
|
|
13
13
|
import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
14
14
|
import { dirname, join } from "node:path";
|
|
15
|
-
import {
|
|
16
|
-
createRuntimePathProvider,
|
|
17
|
-
type RuntimePathProvider,
|
|
18
|
-
} from "../../runtime/runtime-path-provider.js";
|
|
19
15
|
import {
|
|
20
16
|
isPidEntry,
|
|
21
17
|
isSessionOwner,
|
|
@@ -23,7 +19,11 @@ import {
|
|
|
23
19
|
type SessionOwner,
|
|
24
20
|
type SessionPidFile,
|
|
25
21
|
toOwnerKey,
|
|
26
|
-
} from "../../
|
|
22
|
+
} from "../../runtime/pid-schema.js";
|
|
23
|
+
import {
|
|
24
|
+
createRuntimePathProvider,
|
|
25
|
+
type RuntimePathProvider,
|
|
26
|
+
} from "../../runtime/runtime-path-provider.js";
|
|
27
27
|
import { atomicWriteFileSync } from "./atomic-write.js";
|
|
28
28
|
import { acquireFileLock } from "./file-lock.js";
|
|
29
29
|
|
|
@@ -1076,7 +1076,7 @@ export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
|
1076
1076
|
|
|
1077
1077
|
if (!expanded && truncated) {
|
|
1078
1078
|
lines.push(
|
|
1079
|
-
formatPresentationText(theme, "hint", keyHint("
|
|
1079
|
+
formatPresentationText(theme, "hint", keyHint("app.tools.expand", "to show more"))
|
|
1080
1080
|
);
|
|
1081
1081
|
}
|
|
1082
1082
|
} else {
|
|
@@ -283,7 +283,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
283
283
|
name: "cd",
|
|
284
284
|
label: "cd",
|
|
285
285
|
description:
|
|
286
|
-
"Request an interactive workspace transition to another directory. Requires explicit user approval and restarts the turn in the new workspace.",
|
|
286
|
+
"Request an interactive workspace transition to another directory. Requires explicit user approval and restarts the turn in the new workspace. IMPORTANT: cd must be the ONLY tool call in your response — never combine it with other tools (edit, bash, write, etc.). The transition restarts the turn and discards sibling tool results.",
|
|
287
|
+
promptGuidelines: [
|
|
288
|
+
"The cd tool triggers an interactive workspace transition that restarts the current turn. When you need to cd, emit it as the SOLE tool call in your response — do not pair it with edit, bash, write, read, or any other tool. Sibling tool calls will race against the transition and their results will be lost when the turn restarts.",
|
|
289
|
+
],
|
|
287
290
|
parameters: Type.Object({
|
|
288
291
|
path: Type.String({
|
|
289
292
|
description: "Directory path to open via the interactive workspace transition flow",
|
|
@@ -93,6 +93,7 @@ export default function editLive(pi: ExtensionAPI): void {
|
|
|
93
93
|
label: baseEditTool.label,
|
|
94
94
|
description: baseEditTool.description,
|
|
95
95
|
parameters: baseEditTool.parameters,
|
|
96
|
+
prepareArguments: baseEditTool.prepareArguments,
|
|
96
97
|
|
|
97
98
|
renderCall(args, theme) {
|
|
98
99
|
const path = args.path ?? "file";
|
|
@@ -109,7 +110,8 @@ export default function editLive(pi: ExtensionAPI): void {
|
|
|
109
110
|
const filePath = params.path ?? "file";
|
|
110
111
|
const effectiveCwd = ctx?.cwd ?? process.cwd();
|
|
111
112
|
const scopedEditTool = createEditTool(effectiveCwd);
|
|
112
|
-
const
|
|
113
|
+
const prepared = scopedEditTool.prepareArguments?.(params) ?? params;
|
|
114
|
+
const result = await scopedEditTool.execute(toolCallId, prepared, signal, onUpdate);
|
|
113
115
|
const details = result.details as EditToolDetails | undefined;
|
|
114
116
|
const diff = details?.diff ?? "";
|
|
115
117
|
const absoluteFilename = path.isAbsolute(filePath)
|
|
@@ -5,14 +5,23 @@ import { join } from "node:path";
|
|
|
5
5
|
import { type DiagnosticInput, runDiagnostics } from "../index.js";
|
|
6
6
|
|
|
7
7
|
let tmpDir: string;
|
|
8
|
+
let savedTmux: string | undefined;
|
|
8
9
|
|
|
9
10
|
beforeEach(() => {
|
|
10
11
|
tmpDir = join(tmpdir(), `health-diag-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
11
12
|
mkdirSync(tmpDir, { recursive: true });
|
|
13
|
+
// Isolate tests from host tmux environment so tmux diagnostics don't fire
|
|
14
|
+
savedTmux = process.env.TMUX;
|
|
15
|
+
delete process.env.TMUX;
|
|
12
16
|
});
|
|
13
17
|
|
|
14
18
|
afterEach(() => {
|
|
15
19
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
20
|
+
if (savedTmux !== undefined) {
|
|
21
|
+
process.env.TMUX = savedTmux;
|
|
22
|
+
} else {
|
|
23
|
+
delete process.env.TMUX;
|
|
24
|
+
}
|
|
16
25
|
});
|
|
17
26
|
|
|
18
27
|
/**
|
|
@@ -172,4 +181,20 @@ describe("runDiagnostics", () => {
|
|
|
172
181
|
const ctxCheck = checks.find((c) => c.name === "Project context");
|
|
173
182
|
expect(ctxCheck?.status).toBe("pass");
|
|
174
183
|
});
|
|
184
|
+
|
|
185
|
+
test("skips tmux checks when not inside tmux", () => {
|
|
186
|
+
delete process.env.TMUX;
|
|
187
|
+
const checks = runDiagnostics(makeInput());
|
|
188
|
+
const tmuxChecks = checks.filter((c) => c.name.startsWith("tmux"));
|
|
189
|
+
expect(tmuxChecks).toHaveLength(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("includes tmux checks when TMUX env var is set", () => {
|
|
193
|
+
process.env.TMUX = "/tmp/tmux-test/default,12345,0";
|
|
194
|
+
const checks = runDiagnostics(makeInput());
|
|
195
|
+
const tmuxChecks = checks.filter((c) => c.name.startsWith("tmux"));
|
|
196
|
+
// Should have at least escape-time and extended-keys checks
|
|
197
|
+
// (may fail gracefully if tmux binary is not available)
|
|
198
|
+
expect(tmuxChecks.length).toBeGreaterThanOrEqual(0);
|
|
199
|
+
});
|
|
175
200
|
});
|
|
@@ -623,6 +623,67 @@ export function runDiagnostics(input: DiagnosticInput): DiagnosticCheck[] {
|
|
|
623
623
|
});
|
|
624
624
|
}
|
|
625
625
|
|
|
626
|
+
// 9. tmux compatibility (keyboard protocol)
|
|
627
|
+
if (process.env.TMUX) {
|
|
628
|
+
const tmuxChecks = checkTmuxSettings();
|
|
629
|
+
checks.push(...tmuxChecks);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return checks;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Check tmux settings that affect keyboard handling.
|
|
637
|
+
* Only called when running inside tmux ($TMUX is set).
|
|
638
|
+
*
|
|
639
|
+
* @returns Array of diagnostic check results for tmux configuration
|
|
640
|
+
*/
|
|
641
|
+
function checkTmuxSettings(): DiagnosticCheck[] {
|
|
642
|
+
const checks: DiagnosticCheck[] = [];
|
|
643
|
+
|
|
644
|
+
try {
|
|
645
|
+
const { execSync } = require("node:child_process");
|
|
646
|
+
const opts = execSync("tmux show-options -g", { encoding: "utf-8", timeout: 2000 });
|
|
647
|
+
|
|
648
|
+
// Check escape-time (should be 0 or very low for responsive Escape key)
|
|
649
|
+
const escapeTimeMatch = opts.match(/^escape-time\s+(\d+)/m);
|
|
650
|
+
const escapeTime = escapeTimeMatch ? parseInt(escapeTimeMatch[1], 10) : 500;
|
|
651
|
+
if (escapeTime > 50) {
|
|
652
|
+
checks.push({
|
|
653
|
+
name: "tmux escape-time",
|
|
654
|
+
status: "warn",
|
|
655
|
+
message: `${escapeTime}ms delay before Escape is forwarded`,
|
|
656
|
+
suggestion: "Add `set -g escape-time 0` to tmux.conf",
|
|
657
|
+
});
|
|
658
|
+
} else {
|
|
659
|
+
checks.push({
|
|
660
|
+
name: "tmux escape-time",
|
|
661
|
+
status: "pass",
|
|
662
|
+
message: `${escapeTime}ms`,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
// Check extended-keys (needed for Shift+Enter and modified key detection)
|
|
667
|
+
const extKeysMatch = opts.match(/^extended-keys\s+(\S+)/m);
|
|
668
|
+
const extKeys = extKeysMatch ? extKeysMatch[1] : "off";
|
|
669
|
+
if (extKeys === "off") {
|
|
670
|
+
checks.push({
|
|
671
|
+
name: "tmux extended-keys",
|
|
672
|
+
status: "warn",
|
|
673
|
+
message: "Shift+Enter and other modified keys won't work",
|
|
674
|
+
suggestion: "Add `set -g extended-keys on` to tmux.conf",
|
|
675
|
+
});
|
|
676
|
+
} else {
|
|
677
|
+
checks.push({
|
|
678
|
+
name: "tmux extended-keys",
|
|
679
|
+
status: "pass",
|
|
680
|
+
message: extKeys,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
} catch {
|
|
684
|
+
// tmux command failed — skip these checks silently
|
|
685
|
+
}
|
|
686
|
+
|
|
626
687
|
return checks;
|
|
627
688
|
}
|
|
628
689
|
|