@alasano/pi-linear 0.1.1 → 0.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 +14 -2
- package/assets/linear_list_issues.png +0 -0
- package/assets/screenshot.png +0 -0
- package/extensions/renderers/comments.ts +323 -0
- package/extensions/renderers/common.ts +305 -0
- package/extensions/renderers/documents.ts +326 -0
- package/extensions/renderers/initiatives.ts +344 -0
- package/extensions/renderers/issue-labels.ts +294 -0
- package/extensions/renderers/issue-relations.ts +318 -0
- package/extensions/renderers/issue-statuses.ts +199 -0
- package/extensions/renderers/issues.ts +373 -0
- package/extensions/renderers/milestones.ts +294 -0
- package/extensions/renderers/project-labels.ts +279 -0
- package/extensions/renderers/project-relations.ts +344 -0
- package/extensions/renderers/projects.ts +430 -0
- package/extensions/renderers/state.ts +35 -0
- package/extensions/renderers/teams.ts +246 -0
- package/extensions/renderers/users.ts +242 -0
- package/extensions/renderers/workspaces.ts +44 -0
- package/extensions/settings.ts +40 -7
- package/extensions/tools/comments.ts +17 -0
- package/extensions/tools/documents.ts +23 -0
- package/extensions/tools/initiatives.ts +24 -0
- package/extensions/tools/issue-labels.ts +17 -0
- package/extensions/tools/issue-relations.ts +17 -0
- package/extensions/tools/issue-statuses.ts +6 -0
- package/extensions/tools/issues.ts +29 -0
- package/extensions/tools/milestones.ts +18 -0
- package/extensions/tools/project-labels.ts +17 -0
- package/extensions/tools/project-relations.ts +17 -0
- package/extensions/tools/projects.ts +24 -0
- package/extensions/tools/teams.ts +10 -0
- package/extensions/tools/users.ts +10 -0
- package/extensions/tools/workspaces.ts +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AgentToolResult,
|
|
3
|
+
type Theme,
|
|
4
|
+
type ToolRenderResultOptions,
|
|
5
|
+
} from '@mariozechner/pi-coding-agent';
|
|
6
|
+
import { Text } from '@mariozechner/pi-tui';
|
|
7
|
+
import {
|
|
8
|
+
asString,
|
|
9
|
+
cleanOneLine,
|
|
10
|
+
dimStyle,
|
|
11
|
+
expandedJson,
|
|
12
|
+
shouldShowJson,
|
|
13
|
+
jsonHint,
|
|
14
|
+
LinearListResultComponent,
|
|
15
|
+
mutedStyle,
|
|
16
|
+
renderLinearToolCall,
|
|
17
|
+
renderResponsiveTable,
|
|
18
|
+
toolOutputStyle,
|
|
19
|
+
truncate,
|
|
20
|
+
truncateLine,
|
|
21
|
+
type LinearToolRenderContext,
|
|
22
|
+
type TableColumn,
|
|
23
|
+
type ToolArgs,
|
|
24
|
+
} from './common';
|
|
25
|
+
|
|
26
|
+
type ProjectPerson = {
|
|
27
|
+
id?: string;
|
|
28
|
+
name?: string | null;
|
|
29
|
+
email?: string | null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type ProjectTeam = {
|
|
33
|
+
id?: string;
|
|
34
|
+
key?: string | null;
|
|
35
|
+
name?: string | null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type ProjectLike = {
|
|
39
|
+
id?: string;
|
|
40
|
+
name?: string | null;
|
|
41
|
+
description?: string | null;
|
|
42
|
+
state?: string | null;
|
|
43
|
+
status?: { id?: string; name?: string | null } | null;
|
|
44
|
+
priority?: number | null;
|
|
45
|
+
priorityLabel?: string | null;
|
|
46
|
+
health?: string | null;
|
|
47
|
+
progress?: number | null;
|
|
48
|
+
startDate?: string | null;
|
|
49
|
+
targetDate?: string | null;
|
|
50
|
+
url?: string | null;
|
|
51
|
+
lead?: ProjectPerson | null;
|
|
52
|
+
teams?: { nodes?: ProjectTeam[] | null } | null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type ProjectResultDetails = {
|
|
56
|
+
project?: ProjectLike | null;
|
|
57
|
+
projects?: ProjectLike[];
|
|
58
|
+
success?: boolean;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const PROJECT_LIST_PREVIEW_LIMIT = 20;
|
|
62
|
+
const NAME_LIMIT = 90;
|
|
63
|
+
const DESCRIPTION_LIMIT = 180;
|
|
64
|
+
const TABLE_NAME_MIN_WIDTH = 24;
|
|
65
|
+
|
|
66
|
+
function projectDetails(result: AgentToolResult<any>): ProjectResultDetails {
|
|
67
|
+
return (result.details ?? {}) as ProjectResultDetails;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function argsObject(context: { args?: unknown }): ToolArgs {
|
|
71
|
+
return context.args && typeof context.args === 'object' && !Array.isArray(context.args)
|
|
72
|
+
? (context.args as ToolArgs)
|
|
73
|
+
: {};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function valueObject(value: unknown): ToolArgs | undefined {
|
|
77
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
78
|
+
? (value as ToolArgs)
|
|
79
|
+
: undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function humanizeEnum(value: string): string {
|
|
83
|
+
const spaced = value
|
|
84
|
+
.replace(/[_-]+/g, ' ')
|
|
85
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
86
|
+
.trim();
|
|
87
|
+
if (!spaced) return value;
|
|
88
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function projectName(project: ProjectLike): string {
|
|
92
|
+
return truncate(cleanOneLine(asString(project.name) ?? '(untitled project)'), NAME_LIMIT);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function statusText(project: ProjectLike): string | undefined {
|
|
96
|
+
return asString(project.status?.name) ?? asString(project.state);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function priorityText(project: ProjectLike): string | undefined {
|
|
100
|
+
const label = asString(project.priorityLabel);
|
|
101
|
+
if (label) return label;
|
|
102
|
+
|
|
103
|
+
if (typeof project.priority !== 'number') return undefined;
|
|
104
|
+
const priorityLabels: Record<number, string> = {
|
|
105
|
+
0: 'No priority',
|
|
106
|
+
1: 'Urgent',
|
|
107
|
+
2: 'High',
|
|
108
|
+
3: 'Medium',
|
|
109
|
+
4: 'Low',
|
|
110
|
+
};
|
|
111
|
+
return priorityLabels[project.priority] ?? `P${project.priority}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function healthText(project: ProjectLike): string | undefined {
|
|
115
|
+
const health = asString(project.health);
|
|
116
|
+
return health ? humanizeEnum(health) : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function progressText(project: ProjectLike): string | undefined {
|
|
120
|
+
if (typeof project.progress !== 'number' || Number.isNaN(project.progress)) return undefined;
|
|
121
|
+
const percentage = project.progress <= 1 ? project.progress * 100 : project.progress;
|
|
122
|
+
return `${Math.round(percentage)}%`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function teamNames(project: ProjectLike): string[] {
|
|
126
|
+
const nodes = Array.isArray(project.teams?.nodes) ? project.teams.nodes : [];
|
|
127
|
+
return nodes
|
|
128
|
+
.map((team) => asString(team.key) ?? asString(team.name))
|
|
129
|
+
.filter((team): team is string => !!team);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function teamText(project: ProjectLike, limit = 3): string | undefined {
|
|
133
|
+
const teams = teamNames(project);
|
|
134
|
+
if (teams.length === 0) return undefined;
|
|
135
|
+
|
|
136
|
+
const shown = teams.slice(0, limit).join(', ');
|
|
137
|
+
const hiddenCount = teams.length - limit;
|
|
138
|
+
return hiddenCount > 0 ? `${shown}, +${hiddenCount}` : shown;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function allTeamText(project: ProjectLike): string {
|
|
142
|
+
return teamNames(project).join(', ') || '—';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function metadataParts(project: ProjectLike, options: { includeDates?: boolean } = {}): string[] {
|
|
146
|
+
const status = statusText(project);
|
|
147
|
+
const priority = priorityText(project);
|
|
148
|
+
const health = healthText(project);
|
|
149
|
+
const progress = progressText(project);
|
|
150
|
+
const lead = asString(project.lead?.name);
|
|
151
|
+
const teams = teamText(project);
|
|
152
|
+
const startDate = options.includeDates ? asString(project.startDate) : undefined;
|
|
153
|
+
const targetDate = options.includeDates ? asString(project.targetDate) : undefined;
|
|
154
|
+
|
|
155
|
+
return [
|
|
156
|
+
status,
|
|
157
|
+
priority,
|
|
158
|
+
health ? `health: ${health}` : undefined,
|
|
159
|
+
progress ? `progress: ${progress}` : undefined,
|
|
160
|
+
lead ? `lead: ${lead}` : undefined,
|
|
161
|
+
teams ? `teams: ${teams}` : undefined,
|
|
162
|
+
startDate ? `start: ${startDate}` : undefined,
|
|
163
|
+
targetDate ? `target: ${targetDate}` : undefined,
|
|
164
|
+
].filter((part): part is string => !!part);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function formatProjectListLine(project: ProjectLike, theme: Theme, width: number): string {
|
|
168
|
+
const name = projectName(project);
|
|
169
|
+
const metadata = metadataParts(project);
|
|
170
|
+
const suffix = metadata.length ? theme.fg('dim', ` · ${metadata.join(' · ')}`) : '';
|
|
171
|
+
|
|
172
|
+
return truncateLine(` ${theme.fg('toolOutput', name)}${suffix}`, width);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function descriptionSnippet(project: ProjectLike): string | undefined {
|
|
176
|
+
const description = asString(project.description);
|
|
177
|
+
if (!description) return undefined;
|
|
178
|
+
return truncate(cleanOneLine(description), DESCRIPTION_LIMIT);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function statusStyle(theme: Theme, value: string): (text: string) => string {
|
|
182
|
+
const normalized = value.toLowerCase();
|
|
183
|
+
if (normalized === 'completed' || normalized === 'done')
|
|
184
|
+
return (text) => theme.fg('success', text);
|
|
185
|
+
if (normalized === 'canceled' || normalized === 'cancelled')
|
|
186
|
+
return (text) => theme.fg('error', text);
|
|
187
|
+
if (normalized === 'planned' || value === '—') return dimStyle(theme);
|
|
188
|
+
return mutedStyle(theme);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function priorityStyle(theme: Theme, value: string): (text: string) => string {
|
|
192
|
+
const normalized = value.toLowerCase();
|
|
193
|
+
if (normalized === 'urgent') return (text) => theme.fg('error', text);
|
|
194
|
+
if (normalized === 'high') return (text) => theme.fg('warning', text);
|
|
195
|
+
if (normalized === 'low' || normalized === 'no priority' || value === '—') return dimStyle(theme);
|
|
196
|
+
return mutedStyle(theme);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function healthStyle(theme: Theme, value: string): (text: string) => string {
|
|
200
|
+
const normalized = value.toLowerCase();
|
|
201
|
+
if (normalized.includes('track') || normalized === 'healthy')
|
|
202
|
+
return (text) => theme.fg('success', text);
|
|
203
|
+
if (normalized.includes('risk')) return (text) => theme.fg('warning', text);
|
|
204
|
+
if (normalized.includes('off') || normalized.includes('blocked'))
|
|
205
|
+
return (text) => theme.fg('error', text);
|
|
206
|
+
if (value === '—') return dimStyle(theme);
|
|
207
|
+
return mutedStyle(theme);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const PROJECT_TABLE_COLUMNS: TableColumn<ProjectLike>[] = [
|
|
211
|
+
{
|
|
212
|
+
id: 'status',
|
|
213
|
+
label: 'State/Status',
|
|
214
|
+
width: 14,
|
|
215
|
+
value: (project) => statusText(project) ?? '—',
|
|
216
|
+
style: statusStyle,
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
id: 'priority',
|
|
220
|
+
label: 'Priority',
|
|
221
|
+
width: 11,
|
|
222
|
+
value: (project) => priorityText(project) ?? '—',
|
|
223
|
+
style: priorityStyle,
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
id: 'health',
|
|
227
|
+
label: 'Health',
|
|
228
|
+
width: 12,
|
|
229
|
+
value: (project) => healthText(project) ?? '—',
|
|
230
|
+
style: healthStyle,
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
id: 'progress',
|
|
234
|
+
label: 'Progress',
|
|
235
|
+
width: 9,
|
|
236
|
+
value: (project) => progressText(project) ?? '—',
|
|
237
|
+
style: (theme) => mutedStyle(theme),
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
id: 'lead',
|
|
241
|
+
label: 'Lead',
|
|
242
|
+
width: 16,
|
|
243
|
+
value: (project) => asString(project.lead?.name) ?? '—',
|
|
244
|
+
style: (theme) => mutedStyle(theme),
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
id: 'teams',
|
|
248
|
+
label: 'Teams',
|
|
249
|
+
width: 20,
|
|
250
|
+
value: allTeamText,
|
|
251
|
+
style: (theme) => dimStyle(theme),
|
|
252
|
+
},
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
function renderProjectTable(projects: ProjectLike[], theme: Theme, width: number): string[] {
|
|
256
|
+
return renderResponsiveTable(projects, theme, width, {
|
|
257
|
+
columns: PROJECT_TABLE_COLUMNS,
|
|
258
|
+
primary: {
|
|
259
|
+
label: 'Name',
|
|
260
|
+
minWidth: TABLE_NAME_MIN_WIDTH,
|
|
261
|
+
value: projectName,
|
|
262
|
+
style: (theme) => toolOutputStyle(theme),
|
|
263
|
+
},
|
|
264
|
+
dropOrder: ['teams', 'lead', 'progress', 'health', 'priority', 'status'],
|
|
265
|
+
fallback: formatProjectListLine,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function formatProjectTitle(project: ProjectLike, theme: Theme): string {
|
|
270
|
+
const id = asString(project.id);
|
|
271
|
+
const prefix = id ? `${theme.fg('accent', truncate(id, 8))} ` : '';
|
|
272
|
+
return `${prefix}${theme.fg('toolOutput', projectName(project))}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function renderProjectCard(
|
|
276
|
+
actionLabel: string,
|
|
277
|
+
project: ProjectLike | null | undefined,
|
|
278
|
+
theme: Theme,
|
|
279
|
+
): Text {
|
|
280
|
+
if (!project) {
|
|
281
|
+
return new Text(`\n${theme.fg('dim', 'Project not found')}\n\n${jsonHint()}`, 0, 0);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const metadata = metadataParts(project, { includeDates: true });
|
|
285
|
+
const description = descriptionSnippet(project);
|
|
286
|
+
|
|
287
|
+
let text = `\n${theme.fg('success', `✓ ${actionLabel}`)} ${formatProjectTitle(project, theme)}`;
|
|
288
|
+
if (metadata.length) text += `\n ${theme.fg('dim', metadata.join(' · '))}`;
|
|
289
|
+
if (description) text += `\n ${theme.fg('muted', description)}`;
|
|
290
|
+
const url = asString(project.url);
|
|
291
|
+
if (url) text += `\n ${theme.fg('dim', url)}`;
|
|
292
|
+
text += `\n\n${jsonHint()}`;
|
|
293
|
+
|
|
294
|
+
return new Text(text, 0, 0);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function isProjectUpdate(context: { args?: unknown }): boolean {
|
|
298
|
+
const args = argsObject(context);
|
|
299
|
+
if (asString(args.projectId)) return true;
|
|
300
|
+
|
|
301
|
+
const input = valueObject(args.input);
|
|
302
|
+
return !!asString(input?.id);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function renderLinearProjectListCall(args: ToolArgs | undefined, theme: Theme): Text {
|
|
306
|
+
return renderLinearToolCall('linear_list_projects', args, theme, [
|
|
307
|
+
['first', 'first'],
|
|
308
|
+
['last', 'last'],
|
|
309
|
+
['orderBy', 'order'],
|
|
310
|
+
['includeArchived', 'archived'],
|
|
311
|
+
['filter', 'filter'],
|
|
312
|
+
['sort', 'sort'],
|
|
313
|
+
]);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export function renderLinearGetProjectCall(args: ToolArgs | undefined, theme: Theme): Text {
|
|
317
|
+
return renderLinearToolCall('linear_get_project', args, theme, [['projectId', 'projectId']]);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function renderLinearSaveProjectCall(args: ToolArgs | undefined, theme: Theme): Text {
|
|
321
|
+
return renderLinearToolCall('linear_save_project', args, theme, [
|
|
322
|
+
['projectId', 'projectId'],
|
|
323
|
+
['name', 'name'],
|
|
324
|
+
['teamIds', 'teams'],
|
|
325
|
+
['leadId', 'lead'],
|
|
326
|
+
['statusId', 'status'],
|
|
327
|
+
['priority', 'priority'],
|
|
328
|
+
['startDate', 'start'],
|
|
329
|
+
['targetDate', 'target'],
|
|
330
|
+
['input', 'input'],
|
|
331
|
+
]);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function renderLinearDeleteProjectCall(args: ToolArgs | undefined, theme: Theme): Text {
|
|
335
|
+
return renderLinearToolCall('linear_delete_project', args, theme, [['projectId', 'projectId']]);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function renderLinearArchiveProjectCall(args: ToolArgs | undefined, theme: Theme): Text {
|
|
339
|
+
return renderLinearToolCall('linear_archive_project', args, theme, [
|
|
340
|
+
['projectId', 'projectId'],
|
|
341
|
+
['trash', 'trash'],
|
|
342
|
+
]);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function renderLinearUnarchiveProjectCall(args: ToolArgs | undefined, theme: Theme): Text {
|
|
346
|
+
return renderLinearToolCall('linear_unarchive_project', args, theme, [
|
|
347
|
+
['projectId', 'projectId'],
|
|
348
|
+
]);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function renderLinearProjectListResult(
|
|
352
|
+
result: AgentToolResult<any>,
|
|
353
|
+
options: ToolRenderResultOptions,
|
|
354
|
+
theme: Theme,
|
|
355
|
+
context: LinearToolRenderContext,
|
|
356
|
+
): Text | LinearListResultComponent<ProjectLike> {
|
|
357
|
+
if (options.isPartial) return new Text(theme.fg('warning', 'Loading projects…'), 0, 0);
|
|
358
|
+
if (shouldShowJson(options, context)) return expandedJson(result, theme);
|
|
359
|
+
|
|
360
|
+
const projects = Array.isArray(projectDetails(result).projects)
|
|
361
|
+
? (projectDetails(result).projects as ProjectLike[])
|
|
362
|
+
: [];
|
|
363
|
+
|
|
364
|
+
return new LinearListResultComponent(projects, theme, {
|
|
365
|
+
noun: 'project',
|
|
366
|
+
emptyLabel: 'No projects found',
|
|
367
|
+
previewLimit: PROJECT_LIST_PREVIEW_LIMIT,
|
|
368
|
+
renderItems: renderProjectTable,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export function renderLinearProjectResult(actionLabel: string) {
|
|
373
|
+
return (
|
|
374
|
+
result: AgentToolResult<any>,
|
|
375
|
+
options: ToolRenderResultOptions,
|
|
376
|
+
theme: Theme,
|
|
377
|
+
context: LinearToolRenderContext,
|
|
378
|
+
): Text => {
|
|
379
|
+
if (options.isPartial) return new Text(theme.fg('warning', `${actionLabel}…`), 0, 0);
|
|
380
|
+
if (shouldShowJson(options, context)) return expandedJson(result, theme);
|
|
381
|
+
|
|
382
|
+
return renderProjectCard(actionLabel, projectDetails(result).project, theme);
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function renderLinearSaveProjectResult(
|
|
387
|
+
result: AgentToolResult<any>,
|
|
388
|
+
options: ToolRenderResultOptions,
|
|
389
|
+
theme: Theme,
|
|
390
|
+
context: { args?: unknown },
|
|
391
|
+
): Text {
|
|
392
|
+
const actionLabel = isProjectUpdate(context) ? 'Updated project' : 'Created project';
|
|
393
|
+
if (options.isPartial) return new Text(theme.fg('warning', `${actionLabel}…`), 0, 0);
|
|
394
|
+
if (shouldShowJson(options, context)) return expandedJson(result, theme);
|
|
395
|
+
|
|
396
|
+
return renderProjectCard(actionLabel, projectDetails(result).project, theme);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export function renderLinearProjectSuccessResult(defaultActionLabel: string) {
|
|
400
|
+
return (
|
|
401
|
+
result: AgentToolResult<any>,
|
|
402
|
+
options: ToolRenderResultOptions,
|
|
403
|
+
theme: Theme,
|
|
404
|
+
context: { args?: unknown },
|
|
405
|
+
): Text => {
|
|
406
|
+
if (options.isPartial)
|
|
407
|
+
return new Text(theme.fg('warning', `${defaultActionLabel} project…`), 0, 0);
|
|
408
|
+
if (shouldShowJson(options, context)) return expandedJson(result, theme);
|
|
409
|
+
|
|
410
|
+
const details = projectDetails(result);
|
|
411
|
+
const args = argsObject(context);
|
|
412
|
+
const projectId = asString(args.projectId) ?? 'project';
|
|
413
|
+
const actionLabel =
|
|
414
|
+
defaultActionLabel === 'Archived' && args.trash === true ? 'Trashed' : defaultActionLabel;
|
|
415
|
+
|
|
416
|
+
if (details.success !== true) {
|
|
417
|
+
return new Text(
|
|
418
|
+
`\n${theme.fg('warning', `${actionLabel} status unknown`)}\n\n${jsonHint()}`,
|
|
419
|
+
0,
|
|
420
|
+
0,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return new Text(
|
|
425
|
+
`\n${theme.fg('success', `✓ ${actionLabel}`)} ${theme.fg('accent', projectId)}\n\n${jsonHint()}`,
|
|
426
|
+
0,
|
|
427
|
+
0,
|
|
428
|
+
);
|
|
429
|
+
};
|
|
430
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type LinearToolRenderContext = {
|
|
2
|
+
args?: unknown;
|
|
3
|
+
toolCallId?: string;
|
|
4
|
+
invalidate?: () => void;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
let defaultJsonView = false;
|
|
8
|
+
const invalidators = new Map<string, () => void>();
|
|
9
|
+
|
|
10
|
+
export function getDefaultJsonView(): boolean {
|
|
11
|
+
return defaultJsonView;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function setDefaultJsonView(value: boolean): void {
|
|
15
|
+
defaultJsonView = value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function registerLinearResultRenderer(context?: LinearToolRenderContext): void {
|
|
19
|
+
if (typeof context?.invalidate !== 'function') return;
|
|
20
|
+
|
|
21
|
+
const key = context.toolCallId;
|
|
22
|
+
if (!key) return;
|
|
23
|
+
|
|
24
|
+
invalidators.set(key, context.invalidate);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function invalidateLinearResultRenderers(): void {
|
|
28
|
+
for (const invalidate of invalidators.values()) {
|
|
29
|
+
try {
|
|
30
|
+
invalidate();
|
|
31
|
+
} catch {
|
|
32
|
+
// Ignore stale renderer invalidators. They are best-effort UI refresh hooks.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AgentToolResult,
|
|
3
|
+
type Theme,
|
|
4
|
+
type ToolRenderResultOptions,
|
|
5
|
+
} from '@mariozechner/pi-coding-agent';
|
|
6
|
+
import { Text } from '@mariozechner/pi-tui';
|
|
7
|
+
import {
|
|
8
|
+
asString,
|
|
9
|
+
cleanOneLine,
|
|
10
|
+
expandedJson,
|
|
11
|
+
shouldShowJson,
|
|
12
|
+
jsonHint,
|
|
13
|
+
LinearListResultComponent,
|
|
14
|
+
renderLinearToolCall,
|
|
15
|
+
renderResponsiveTable,
|
|
16
|
+
toolOutputStyle,
|
|
17
|
+
truncate,
|
|
18
|
+
truncateLine,
|
|
19
|
+
type LinearToolRenderContext,
|
|
20
|
+
type TableColumn,
|
|
21
|
+
type ToolArgs,
|
|
22
|
+
} from './common';
|
|
23
|
+
|
|
24
|
+
type WorkflowStateLike = {
|
|
25
|
+
id?: string | null;
|
|
26
|
+
name?: string | null;
|
|
27
|
+
type?: string | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type TeamLike = {
|
|
31
|
+
id?: string | null;
|
|
32
|
+
key?: string | null;
|
|
33
|
+
name?: string | null;
|
|
34
|
+
description?: string | null;
|
|
35
|
+
color?: string | null;
|
|
36
|
+
icon?: string | null;
|
|
37
|
+
private?: boolean | null;
|
|
38
|
+
states?: { nodes?: WorkflowStateLike[] | null } | null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type TeamResultDetails = {
|
|
42
|
+
team?: TeamLike | null;
|
|
43
|
+
teams?: TeamLike[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const TEAM_LIST_PREVIEW_LIMIT = 20;
|
|
47
|
+
const NAME_LIMIT = 90;
|
|
48
|
+
const DESCRIPTION_LIMIT = 160;
|
|
49
|
+
const TABLE_NAME_MIN_WIDTH = 24;
|
|
50
|
+
const STATE_TYPE_ORDER = ['backlog', 'unstarted', 'started', 'completed', 'canceled', 'cancelled'];
|
|
51
|
+
|
|
52
|
+
function teamDetails(result: AgentToolResult<any>): TeamResultDetails {
|
|
53
|
+
return (result.details ?? {}) as TeamResultDetails;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function teamName(team: TeamLike): string {
|
|
57
|
+
return truncate(cleanOneLine(asString(team.name) ?? '(unnamed team)'), NAME_LIMIT);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function teamKey(team: TeamLike): string {
|
|
61
|
+
return asString(team.key) ?? asString(team.id) ?? '—';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function privateText(team: TeamLike): string | undefined {
|
|
65
|
+
if (typeof team.private !== 'boolean') return undefined;
|
|
66
|
+
return team.private ? 'yes' : 'no';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function stateNodes(team: TeamLike): WorkflowStateLike[] {
|
|
70
|
+
const nodes = team.states?.nodes;
|
|
71
|
+
return Array.isArray(nodes) ? nodes : [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function stateTypeRank(type: string): number {
|
|
75
|
+
const index = STATE_TYPE_ORDER.indexOf(type.toLowerCase());
|
|
76
|
+
return index === -1 ? STATE_TYPE_ORDER.length : index;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function statesSummary(team: TeamLike): string | undefined {
|
|
80
|
+
const states = stateNodes(team);
|
|
81
|
+
if (states.length === 0) return undefined;
|
|
82
|
+
|
|
83
|
+
const counts = new Map<string, number>();
|
|
84
|
+
for (const state of states) {
|
|
85
|
+
const type = asString(state.type);
|
|
86
|
+
if (!type) continue;
|
|
87
|
+
counts.set(type, (counts.get(type) ?? 0) + 1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (counts.size === 0) return `${states.length} states`;
|
|
91
|
+
|
|
92
|
+
return [...counts.entries()]
|
|
93
|
+
.sort(
|
|
94
|
+
([left], [right]) => stateTypeRank(left) - stateTypeRank(right) || left.localeCompare(right),
|
|
95
|
+
)
|
|
96
|
+
.map(([type, count]) => `${type} ${count}`)
|
|
97
|
+
.join(' · ');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function descriptionSnippet(team: TeamLike): string | undefined {
|
|
101
|
+
const description = asString(team.description);
|
|
102
|
+
if (!description) return undefined;
|
|
103
|
+
return truncate(cleanOneLine(description), DESCRIPTION_LIMIT);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function metadataParts(team: TeamLike): string[] {
|
|
107
|
+
const privacy = privateText(team);
|
|
108
|
+
const states = statesSummary(team);
|
|
109
|
+
const color = asString(team.color);
|
|
110
|
+
const icon = asString(team.icon);
|
|
111
|
+
const description = descriptionSnippet(team);
|
|
112
|
+
|
|
113
|
+
return [
|
|
114
|
+
privacy ? `private: ${privacy}` : undefined,
|
|
115
|
+
states ? `states: ${states}` : undefined,
|
|
116
|
+
color ? `color: ${color}` : undefined,
|
|
117
|
+
icon ? `icon: ${icon}` : undefined,
|
|
118
|
+
description,
|
|
119
|
+
].filter((part): part is string => !!part);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function formatTeamListLine(team: TeamLike, theme: Theme, width: number): string {
|
|
123
|
+
const key = teamKey(team);
|
|
124
|
+
const keyPrefix = key === '—' ? '' : `${theme.fg('accent', key)} `;
|
|
125
|
+
const metadata = metadataParts(team);
|
|
126
|
+
const suffix = metadata.length ? theme.fg('dim', ` · ${metadata.join(' · ')}`) : '';
|
|
127
|
+
|
|
128
|
+
return truncateLine(` ${keyPrefix}${theme.fg('toolOutput', teamName(team))}${suffix}`, width);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function privateStyle(theme: Theme, value: string): (text: string) => string {
|
|
132
|
+
if (value === 'yes') return (text) => theme.fg('warning', text);
|
|
133
|
+
if (value === 'no' || value === '—') return (text) => theme.fg('dim', text);
|
|
134
|
+
return (text) => theme.fg('muted', text);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const TEAM_TABLE_COLUMNS: TableColumn<TeamLike>[] = [
|
|
138
|
+
{
|
|
139
|
+
id: 'key',
|
|
140
|
+
label: 'Key',
|
|
141
|
+
width: 10,
|
|
142
|
+
value: teamKey,
|
|
143
|
+
style: (theme, value) =>
|
|
144
|
+
value === '—' ? (text) => theme.fg('dim', text) : (text) => theme.fg('accent', text),
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
id: 'private',
|
|
148
|
+
label: 'Private',
|
|
149
|
+
width: 8,
|
|
150
|
+
value: (team) => privateText(team) ?? '—',
|
|
151
|
+
style: privateStyle,
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
id: 'states',
|
|
155
|
+
label: 'States',
|
|
156
|
+
width: 36,
|
|
157
|
+
value: (team) => statesSummary(team) ?? '—',
|
|
158
|
+
style: (theme) => (text) => theme.fg('dim', text),
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
function renderTeamTable(teams: TeamLike[], theme: Theme, width: number): string[] {
|
|
163
|
+
return renderResponsiveTable(teams, theme, width, {
|
|
164
|
+
columns: TEAM_TABLE_COLUMNS,
|
|
165
|
+
primary: {
|
|
166
|
+
label: 'Name',
|
|
167
|
+
minWidth: TABLE_NAME_MIN_WIDTH,
|
|
168
|
+
value: teamName,
|
|
169
|
+
style: (theme) => toolOutputStyle(theme),
|
|
170
|
+
},
|
|
171
|
+
dropOrder: ['states', 'private', 'key'],
|
|
172
|
+
fallback: formatTeamListLine,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function formatTeamTitle(team: TeamLike, theme: Theme): string {
|
|
177
|
+
const key = teamKey(team);
|
|
178
|
+
const prefix = key === '—' ? '' : `${theme.fg('accent', key)} `;
|
|
179
|
+
return `${prefix}${theme.fg('toolOutput', teamName(team))}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function renderTeamCard(
|
|
183
|
+
actionLabel: string,
|
|
184
|
+
team: TeamLike | null | undefined,
|
|
185
|
+
theme: Theme,
|
|
186
|
+
): Text {
|
|
187
|
+
if (!team) {
|
|
188
|
+
return new Text(`\n${theme.fg('dim', 'Team not found')}\n\n${jsonHint()}`, 0, 0);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const metadata = metadataParts(team);
|
|
192
|
+
|
|
193
|
+
let text = `\n${theme.fg('success', `✓ ${actionLabel}`)} ${formatTeamTitle(team, theme)}`;
|
|
194
|
+
if (metadata.length) text += `\n ${theme.fg('dim', metadata.join(' · '))}`;
|
|
195
|
+
text += `\n\n${jsonHint()}`;
|
|
196
|
+
|
|
197
|
+
return new Text(text, 0, 0);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function renderLinearTeamListCall(args: ToolArgs | undefined, theme: Theme): Text {
|
|
201
|
+
return renderLinearToolCall('linear_list_teams', args, theme, [
|
|
202
|
+
['first', 'first'],
|
|
203
|
+
['orderBy', 'order'],
|
|
204
|
+
['filter', 'filter'],
|
|
205
|
+
['includeArchived', 'archived'],
|
|
206
|
+
]);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function renderLinearGetTeamCall(args: ToolArgs | undefined, theme: Theme): Text {
|
|
210
|
+
return renderLinearToolCall('linear_get_team', args, theme, [['teamId', 'teamId']]);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function renderLinearTeamListResult(
|
|
214
|
+
result: AgentToolResult<any>,
|
|
215
|
+
options: ToolRenderResultOptions,
|
|
216
|
+
theme: Theme,
|
|
217
|
+
context: LinearToolRenderContext,
|
|
218
|
+
): Text | LinearListResultComponent<TeamLike> {
|
|
219
|
+
if (options.isPartial) return new Text(theme.fg('warning', 'Loading teams…'), 0, 0);
|
|
220
|
+
if (shouldShowJson(options, context)) return expandedJson(result, theme);
|
|
221
|
+
|
|
222
|
+
const teams = Array.isArray(teamDetails(result).teams)
|
|
223
|
+
? (teamDetails(result).teams as TeamLike[])
|
|
224
|
+
: [];
|
|
225
|
+
|
|
226
|
+
return new LinearListResultComponent(teams, theme, {
|
|
227
|
+
noun: 'team',
|
|
228
|
+
emptyLabel: 'No teams found',
|
|
229
|
+
previewLimit: TEAM_LIST_PREVIEW_LIMIT,
|
|
230
|
+
renderItems: renderTeamTable,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function renderLinearTeamResult(actionLabel: string) {
|
|
235
|
+
return (
|
|
236
|
+
result: AgentToolResult<any>,
|
|
237
|
+
options: ToolRenderResultOptions,
|
|
238
|
+
theme: Theme,
|
|
239
|
+
context: LinearToolRenderContext,
|
|
240
|
+
): Text => {
|
|
241
|
+
if (options.isPartial) return new Text(theme.fg('warning', `${actionLabel}…`), 0, 0);
|
|
242
|
+
if (shouldShowJson(options, context)) return expandedJson(result, theme);
|
|
243
|
+
|
|
244
|
+
return renderTeamCard(actionLabel, teamDetails(result).team, theme);
|
|
245
|
+
};
|
|
246
|
+
}
|