tmux-team 4.0.0 → 4.2.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/README.md +123 -25
- package/package.json +15 -16
- package/src/cli.test.ts +15 -1
- package/src/cli.ts +15 -1
- package/src/commands/add.ts +17 -32
- package/src/commands/basic-commands.test.ts +534 -17
- package/src/commands/check.ts +20 -0
- package/src/commands/completion.ts +6 -8
- package/src/commands/config-command.test.ts +9 -8
- package/src/commands/config.ts +1 -5
- package/src/commands/help.ts +8 -3
- package/src/commands/install.test.ts +15 -1
- package/src/commands/list.ts +21 -2
- package/src/commands/migrate.ts +84 -0
- package/src/commands/preamble.test.ts +15 -2
- package/src/commands/preamble.ts +61 -16
- package/src/commands/remove.ts +10 -6
- package/src/commands/talk.test.ts +132 -22
- package/src/commands/talk.ts +28 -3
- package/src/commands/team.ts +361 -0
- package/src/commands/update.ts +45 -14
- package/src/config.test.ts +24 -0
- package/src/config.ts +37 -3
- package/src/context.test.ts +76 -1
- package/src/context.ts +8 -1
- package/src/identity.test.ts +3 -9
- package/src/identity.ts +7 -9
- package/src/registry.test.ts +61 -0
- package/src/registry.ts +29 -0
- package/src/tmux.test.ts +190 -1
- package/src/tmux.ts +289 -9
- package/src/types.ts +55 -0
- package/src/ui.test.ts +7 -1
package/src/tmux.ts
CHANGED
|
@@ -2,9 +2,19 @@
|
|
|
2
2
|
// Pure tmux wrapper - buffer paste, capture-pane, pane detection
|
|
3
3
|
// ─────────────────────────────────────────────────────────────
|
|
4
4
|
|
|
5
|
-
import { execSync } from 'child_process';
|
|
5
|
+
import { execFileSync, execSync } from 'child_process';
|
|
6
6
|
import crypto from 'crypto';
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
AgentRegistration,
|
|
9
|
+
PaneAgentMetadata,
|
|
10
|
+
Tmux,
|
|
11
|
+
PaneInfo,
|
|
12
|
+
RegistryScope,
|
|
13
|
+
TeamPaneInfo,
|
|
14
|
+
TmuxRegistry,
|
|
15
|
+
} from './types.js';
|
|
16
|
+
|
|
17
|
+
const AGENT_METADATA_OPTION = '@tmux-team.agent';
|
|
8
18
|
|
|
9
19
|
// Known agent patterns for auto-detection
|
|
10
20
|
const KNOWN_AGENTS: Record<string, string[]> = {
|
|
@@ -27,6 +37,151 @@ function detectAgentName(command: string): string | null {
|
|
|
27
37
|
return null;
|
|
28
38
|
}
|
|
29
39
|
|
|
40
|
+
function safeParseMetadata(text: string): PaneAgentMetadata | undefined {
|
|
41
|
+
if (!text.trim()) return undefined;
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(text) as PaneAgentMetadata;
|
|
44
|
+
if (!parsed || parsed.version !== 1 || typeof parsed !== 'object') {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
return parsed;
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function emptyMetadata(): PaneAgentMetadata {
|
|
54
|
+
return { version: 1 };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function registrationForScope(
|
|
58
|
+
metadata: PaneAgentMetadata | undefined,
|
|
59
|
+
scope: RegistryScope
|
|
60
|
+
): AgentRegistration | undefined {
|
|
61
|
+
if (!metadata) return undefined;
|
|
62
|
+
if (scope.type === 'team') {
|
|
63
|
+
return metadata.teams?.[scope.teamName];
|
|
64
|
+
}
|
|
65
|
+
return metadata.workspaces?.[scope.workspaceRoot];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function setRegistrationForScope(
|
|
69
|
+
metadata: PaneAgentMetadata,
|
|
70
|
+
scope: RegistryScope,
|
|
71
|
+
registration: AgentRegistration
|
|
72
|
+
): PaneAgentMetadata {
|
|
73
|
+
if (scope.type === 'team') {
|
|
74
|
+
metadata.teams = { ...metadata.teams, [scope.teamName]: registration };
|
|
75
|
+
} else {
|
|
76
|
+
metadata.workspaces = {
|
|
77
|
+
...metadata.workspaces,
|
|
78
|
+
[scope.workspaceRoot]: registration,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return metadata;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function deleteRegistrationForScope(
|
|
85
|
+
metadata: PaneAgentMetadata,
|
|
86
|
+
scope: RegistryScope
|
|
87
|
+
): AgentRegistration | undefined {
|
|
88
|
+
let removed: AgentRegistration | undefined;
|
|
89
|
+
if (scope.type === 'team') {
|
|
90
|
+
removed = metadata.teams?.[scope.teamName];
|
|
91
|
+
if (metadata.teams) {
|
|
92
|
+
delete metadata.teams[scope.teamName];
|
|
93
|
+
if (Object.keys(metadata.teams).length === 0) delete metadata.teams;
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
removed = metadata.workspaces?.[scope.workspaceRoot];
|
|
97
|
+
if (metadata.workspaces) {
|
|
98
|
+
delete metadata.workspaces[scope.workspaceRoot];
|
|
99
|
+
if (Object.keys(metadata.workspaces).length === 0) delete metadata.workspaces;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return removed;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function hasRegistrations(metadata: PaneAgentMetadata): boolean {
|
|
106
|
+
return Boolean(
|
|
107
|
+
(metadata.workspaces && Object.keys(metadata.workspaces).length > 0) ||
|
|
108
|
+
(metadata.teams && Object.keys(metadata.teams).length > 0)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function registryFromPanes(panes: PaneInfo[], scope: RegistryScope): TmuxRegistry {
|
|
113
|
+
const paneRegistry: TmuxRegistry['paneRegistry'] = {};
|
|
114
|
+
const agents: TmuxRegistry['agents'] = {};
|
|
115
|
+
|
|
116
|
+
for (const pane of panes) {
|
|
117
|
+
const registration = registrationForScope(pane.metadata, scope);
|
|
118
|
+
if (!registration || paneRegistry[registration.name]) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
paneRegistry[registration.name] = {
|
|
123
|
+
pane: pane.id,
|
|
124
|
+
...(registration.remark !== undefined && { remark: registration.remark }),
|
|
125
|
+
...(registration.preamble !== undefined && { preamble: registration.preamble }),
|
|
126
|
+
...(registration.deny !== undefined && { deny: registration.deny }),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
if (
|
|
130
|
+
Object.prototype.hasOwnProperty.call(registration, 'preamble') ||
|
|
131
|
+
Object.prototype.hasOwnProperty.call(registration, 'deny')
|
|
132
|
+
) {
|
|
133
|
+
agents[registration.name] = {
|
|
134
|
+
...(Object.prototype.hasOwnProperty.call(registration, 'preamble') && {
|
|
135
|
+
preamble: registration.preamble,
|
|
136
|
+
}),
|
|
137
|
+
...(Object.prototype.hasOwnProperty.call(registration, 'deny') && {
|
|
138
|
+
deny: registration.deny,
|
|
139
|
+
}),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { paneRegistry, agents };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function teamPaneInfoFromPane(pane: PaneInfo): TeamPaneInfo {
|
|
148
|
+
const registrations: TeamPaneInfo['registrations'] = [];
|
|
149
|
+
|
|
150
|
+
for (const [workspaceRoot, registration] of Object.entries(pane.metadata?.workspaces ?? {})) {
|
|
151
|
+
registrations.push({
|
|
152
|
+
scopeType: 'workspace',
|
|
153
|
+
scope: workspaceRoot,
|
|
154
|
+
agent: registration.name,
|
|
155
|
+
...(registration.remark !== undefined && { remark: registration.remark }),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const [teamName, registration] of Object.entries(pane.metadata?.teams ?? {})) {
|
|
160
|
+
registrations.push({
|
|
161
|
+
scopeType: 'team',
|
|
162
|
+
scope: teamName,
|
|
163
|
+
agent: registration.name,
|
|
164
|
+
...(registration.remark !== undefined && { remark: registration.remark }),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
registrations.sort(
|
|
169
|
+
(a, b) =>
|
|
170
|
+
a.scopeType.localeCompare(b.scopeType) ||
|
|
171
|
+
a.scope.localeCompare(b.scope) ||
|
|
172
|
+
a.agent.localeCompare(b.agent)
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
pane: pane.id,
|
|
177
|
+
...(pane.target && { target: pane.target }),
|
|
178
|
+
...(pane.cwd && { cwd: pane.cwd }),
|
|
179
|
+
command: pane.command,
|
|
180
|
+
suggestedName: pane.suggestedName,
|
|
181
|
+
registrations,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
30
185
|
export function createTmux(): Tmux {
|
|
31
186
|
function sleepMs(ms: number): void {
|
|
32
187
|
if (ms <= 0) return;
|
|
@@ -88,29 +243,57 @@ export function createTmux(): Tmux {
|
|
|
88
243
|
|
|
89
244
|
listPanes(): PaneInfo[] {
|
|
90
245
|
try {
|
|
91
|
-
// Get all panes with
|
|
92
|
-
const output = execSync(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
246
|
+
// Get all panes with stable IDs, human tmux targets, cwd, commands, and tmux-team metadata.
|
|
247
|
+
const output = execSync(
|
|
248
|
+
`tmux list-panes -a -F "#{pane_id}\t#{session_name}:#{window_index}.#{pane_index}\t#{pane_current_path}\t#{pane_current_command}\t#{${AGENT_METADATA_OPTION}}"`,
|
|
249
|
+
{
|
|
250
|
+
encoding: 'utf-8',
|
|
251
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
252
|
+
}
|
|
253
|
+
);
|
|
96
254
|
|
|
255
|
+
const seen = new Set<string>();
|
|
97
256
|
return output
|
|
98
|
-
.trim()
|
|
99
257
|
.split('\n')
|
|
100
258
|
.filter((line) => line.trim())
|
|
101
259
|
.map((line) => {
|
|
102
|
-
const
|
|
260
|
+
const fields = line.split('\t');
|
|
261
|
+
const [id, target, cwd, command, metadataText = ''] =
|
|
262
|
+
fields.length >= 5
|
|
263
|
+
? fields
|
|
264
|
+
: [fields[0], undefined, undefined, fields[1] ?? '', fields[2] ?? ''];
|
|
265
|
+
const metadata = safeParseMetadata(metadataText);
|
|
103
266
|
return {
|
|
104
267
|
id: id || '',
|
|
268
|
+
...(target && { target }),
|
|
269
|
+
...(cwd && { cwd }),
|
|
105
270
|
command: command || '',
|
|
106
271
|
suggestedName: detectAgentName(command || ''),
|
|
272
|
+
...(metadata && { metadata }),
|
|
107
273
|
};
|
|
274
|
+
})
|
|
275
|
+
.filter((pane) => {
|
|
276
|
+
if (!pane.id || seen.has(pane.id)) return false;
|
|
277
|
+
seen.add(pane.id);
|
|
278
|
+
return true;
|
|
108
279
|
});
|
|
109
280
|
} catch {
|
|
110
281
|
return [];
|
|
111
282
|
}
|
|
112
283
|
},
|
|
113
284
|
|
|
285
|
+
resolvePaneTarget(target: string): string | null {
|
|
286
|
+
try {
|
|
287
|
+
const output = execFileSync('tmux', ['display-message', '-p', '-t', target, '#{pane_id}'], {
|
|
288
|
+
encoding: 'utf-8',
|
|
289
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
290
|
+
});
|
|
291
|
+
return output.trim() || null;
|
|
292
|
+
} catch {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
|
|
114
297
|
getCurrentPaneId(): string | null {
|
|
115
298
|
// First check environment variable
|
|
116
299
|
if (process.env.TMUX_PANE) {
|
|
@@ -128,5 +311,102 @@ export function createTmux(): Tmux {
|
|
|
128
311
|
return null;
|
|
129
312
|
}
|
|
130
313
|
},
|
|
314
|
+
|
|
315
|
+
getAgentRegistry(scope: RegistryScope): TmuxRegistry {
|
|
316
|
+
return registryFromPanes(this.listPanes(), scope);
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
setAgentRegistration(
|
|
320
|
+
paneId: string,
|
|
321
|
+
scope: RegistryScope,
|
|
322
|
+
registration: AgentRegistration
|
|
323
|
+
): void {
|
|
324
|
+
const metadata = readPaneMetadata(paneId);
|
|
325
|
+
const next = setRegistrationForScope(metadata, scope, registration);
|
|
326
|
+
writePaneMetadata(paneId, next);
|
|
327
|
+
},
|
|
328
|
+
|
|
329
|
+
clearAgentRegistration(name: string, scope: RegistryScope): boolean {
|
|
330
|
+
let removed = false;
|
|
331
|
+
for (const pane of this.listPanes()) {
|
|
332
|
+
const registration = registrationForScope(pane.metadata, scope);
|
|
333
|
+
if (registration?.name !== name) continue;
|
|
334
|
+
|
|
335
|
+
const metadata = pane.metadata ?? emptyMetadata();
|
|
336
|
+
deleteRegistrationForScope(metadata, scope);
|
|
337
|
+
writePaneMetadata(pane.id, metadata);
|
|
338
|
+
removed = true;
|
|
339
|
+
}
|
|
340
|
+
return removed;
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
listTeams(): Record<string, string[]> {
|
|
344
|
+
const teams: Record<string, Set<string>> = {};
|
|
345
|
+
for (const pane of this.listPanes()) {
|
|
346
|
+
for (const [teamName, registration] of Object.entries(pane.metadata?.teams ?? {})) {
|
|
347
|
+
if (!teams[teamName]) teams[teamName] = new Set<string>();
|
|
348
|
+
teams[teamName].add(registration.name);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return Object.fromEntries(
|
|
352
|
+
Object.entries(teams).map(([teamName, agents]) => [teamName, [...agents].sort()])
|
|
353
|
+
);
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
listTeamPanes(): TeamPaneInfo[] {
|
|
357
|
+
return this.listPanes().map(teamPaneInfoFromPane);
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
removeTeam(teamName: string): { removed: number; agents: string[] } {
|
|
361
|
+
const agents = new Set<string>();
|
|
362
|
+
let removed = 0;
|
|
363
|
+
for (const pane of this.listPanes()) {
|
|
364
|
+
if (!pane.metadata?.teams?.[teamName]) continue;
|
|
365
|
+
|
|
366
|
+
agents.add(pane.metadata.teams[teamName].name);
|
|
367
|
+
const metadata = pane.metadata;
|
|
368
|
+
const teamRegistrations = metadata.teams;
|
|
369
|
+
if (teamRegistrations) {
|
|
370
|
+
delete teamRegistrations[teamName];
|
|
371
|
+
if (Object.keys(teamRegistrations).length === 0) delete metadata.teams;
|
|
372
|
+
}
|
|
373
|
+
writePaneMetadata(pane.id, metadata);
|
|
374
|
+
removed += 1;
|
|
375
|
+
}
|
|
376
|
+
return { removed, agents: [...agents].sort() };
|
|
377
|
+
},
|
|
131
378
|
};
|
|
132
379
|
}
|
|
380
|
+
|
|
381
|
+
function readPaneMetadata(paneId: string): PaneAgentMetadata {
|
|
382
|
+
try {
|
|
383
|
+
const output = execFileSync(
|
|
384
|
+
'tmux',
|
|
385
|
+
['show-options', '-p', '-t', paneId, '-v', AGENT_METADATA_OPTION],
|
|
386
|
+
{
|
|
387
|
+
encoding: 'utf-8',
|
|
388
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
389
|
+
}
|
|
390
|
+
);
|
|
391
|
+
return safeParseMetadata(output) ?? emptyMetadata();
|
|
392
|
+
} catch {
|
|
393
|
+
return emptyMetadata();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function writePaneMetadata(paneId: string, metadata: PaneAgentMetadata): void {
|
|
398
|
+
if (!hasRegistrations(metadata)) {
|
|
399
|
+
execFileSync('tmux', ['set-option', '-p', '-u', '-t', paneId, AGENT_METADATA_OPTION], {
|
|
400
|
+
stdio: 'pipe',
|
|
401
|
+
});
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
execFileSync(
|
|
406
|
+
'tmux',
|
|
407
|
+
['set-option', '-p', '-t', paneId, AGENT_METADATA_OPTION, JSON.stringify(metadata)],
|
|
408
|
+
{
|
|
409
|
+
stdio: 'pipe',
|
|
410
|
+
}
|
|
411
|
+
);
|
|
412
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -14,6 +14,24 @@ export interface PaneEntry {
|
|
|
14
14
|
deny?: string[]; // Permission deny patterns
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
export interface AgentRegistration {
|
|
18
|
+
name: string;
|
|
19
|
+
remark?: string;
|
|
20
|
+
preamble?: string;
|
|
21
|
+
deny?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PaneAgentMetadata {
|
|
25
|
+
version: 1;
|
|
26
|
+
workspaces?: Record<string, AgentRegistration>;
|
|
27
|
+
teams?: Record<string, AgentRegistration>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface TmuxRegistry {
|
|
31
|
+
paneRegistry: Record<string, PaneEntry>;
|
|
32
|
+
agents: Record<string, AgentConfig>;
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
export interface ConfigDefaults {
|
|
18
36
|
timeout: number; // seconds
|
|
19
37
|
pollInterval: number; // seconds
|
|
@@ -51,6 +69,7 @@ export interface ResolvedConfig {
|
|
|
51
69
|
defaults: ConfigDefaults;
|
|
52
70
|
agents: Record<string, AgentConfig>;
|
|
53
71
|
paneRegistry: Record<string, PaneEntry>;
|
|
72
|
+
registrySource?: 'tmux' | 'legacy' | 'none';
|
|
54
73
|
}
|
|
55
74
|
|
|
56
75
|
export interface Flags {
|
|
@@ -72,6 +91,7 @@ export interface Paths {
|
|
|
72
91
|
globalConfig: string;
|
|
73
92
|
localConfig: string;
|
|
74
93
|
stateFile: string;
|
|
94
|
+
workspaceRoot?: string;
|
|
75
95
|
}
|
|
76
96
|
|
|
77
97
|
export interface UI {
|
|
@@ -85,8 +105,27 @@ export interface UI {
|
|
|
85
105
|
|
|
86
106
|
export interface PaneInfo {
|
|
87
107
|
id: string; // e.g., "%1"
|
|
108
|
+
target?: string; // e.g., "main:1.0"
|
|
109
|
+
cwd?: string; // pane_current_path
|
|
88
110
|
command: string; // e.g., "node", "python", "zsh"
|
|
89
111
|
suggestedName: string | null; // e.g., "codex" if detected from command
|
|
112
|
+
metadata?: PaneAgentMetadata;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface TeamPaneRegistration {
|
|
116
|
+
scopeType: 'workspace' | 'team';
|
|
117
|
+
scope: string;
|
|
118
|
+
agent: string;
|
|
119
|
+
remark?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface TeamPaneInfo {
|
|
123
|
+
pane: string;
|
|
124
|
+
target?: string;
|
|
125
|
+
cwd?: string;
|
|
126
|
+
command: string;
|
|
127
|
+
suggestedName: string | null;
|
|
128
|
+
registrations: TeamPaneRegistration[];
|
|
90
129
|
}
|
|
91
130
|
|
|
92
131
|
export interface Tmux {
|
|
@@ -94,8 +133,23 @@ export interface Tmux {
|
|
|
94
133
|
capture: (paneId: string, lines: number) => string;
|
|
95
134
|
listPanes: () => PaneInfo[];
|
|
96
135
|
getCurrentPaneId: () => string | null;
|
|
136
|
+
resolvePaneTarget: (target: string) => string | null;
|
|
137
|
+
getAgentRegistry: (scope: RegistryScope) => TmuxRegistry;
|
|
138
|
+
setAgentRegistration: (
|
|
139
|
+
paneId: string,
|
|
140
|
+
scope: RegistryScope,
|
|
141
|
+
registration: AgentRegistration
|
|
142
|
+
) => void;
|
|
143
|
+
clearAgentRegistration: (name: string, scope: RegistryScope) => boolean;
|
|
144
|
+
listTeams: () => Record<string, string[]>;
|
|
145
|
+
listTeamPanes: () => TeamPaneInfo[];
|
|
146
|
+
removeTeam: (teamName: string) => { removed: number; agents: string[] };
|
|
97
147
|
}
|
|
98
148
|
|
|
149
|
+
export type RegistryScope =
|
|
150
|
+
| { type: 'workspace'; workspaceRoot: string }
|
|
151
|
+
| { type: 'team'; teamName: string };
|
|
152
|
+
|
|
99
153
|
export interface WaitResult {
|
|
100
154
|
requestId: string;
|
|
101
155
|
nonce: string;
|
|
@@ -110,5 +164,6 @@ export interface Context {
|
|
|
110
164
|
config: ResolvedConfig;
|
|
111
165
|
tmux: Tmux;
|
|
112
166
|
paths: Paths;
|
|
167
|
+
registryScope?: RegistryScope;
|
|
113
168
|
exit: (code: number) => never;
|
|
114
169
|
}
|
package/src/ui.test.ts
CHANGED
|
@@ -101,7 +101,13 @@ describe('ui', () => {
|
|
|
101
101
|
const { createUI } = await import('./ui.js');
|
|
102
102
|
const ui = createUI(false);
|
|
103
103
|
|
|
104
|
-
ui.table(
|
|
104
|
+
ui.table(
|
|
105
|
+
['Name', 'Value'],
|
|
106
|
+
[
|
|
107
|
+
['test', ''],
|
|
108
|
+
['empty', null as unknown as string],
|
|
109
|
+
]
|
|
110
|
+
);
|
|
105
111
|
const output = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
|
106
112
|
// Empty cells should be rendered as '-'
|
|
107
113
|
expect(output).toContain('-');
|