@alasano/pi-linear 0.1.0 → 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.
Files changed (36) hide show
  1. package/README.md +14 -2
  2. package/assets/linear_list_issues.png +0 -0
  3. package/assets/screenshot.png +0 -0
  4. package/extensions/index.ts +1 -1
  5. package/extensions/renderers/comments.ts +323 -0
  6. package/extensions/renderers/common.ts +305 -0
  7. package/extensions/renderers/documents.ts +326 -0
  8. package/extensions/renderers/initiatives.ts +344 -0
  9. package/extensions/renderers/issue-labels.ts +294 -0
  10. package/extensions/renderers/issue-relations.ts +318 -0
  11. package/extensions/renderers/issue-statuses.ts +199 -0
  12. package/extensions/renderers/issues.ts +373 -0
  13. package/extensions/renderers/milestones.ts +294 -0
  14. package/extensions/renderers/project-labels.ts +279 -0
  15. package/extensions/renderers/project-relations.ts +344 -0
  16. package/extensions/renderers/projects.ts +430 -0
  17. package/extensions/renderers/state.ts +35 -0
  18. package/extensions/renderers/teams.ts +246 -0
  19. package/extensions/renderers/users.ts +242 -0
  20. package/extensions/renderers/workspaces.ts +44 -0
  21. package/extensions/settings.ts +53 -23
  22. package/extensions/tools/comments.ts +17 -0
  23. package/extensions/tools/documents.ts +23 -0
  24. package/extensions/tools/initiatives.ts +24 -0
  25. package/extensions/tools/issue-labels.ts +17 -0
  26. package/extensions/tools/issue-relations.ts +29 -5
  27. package/extensions/tools/issue-statuses.ts +6 -0
  28. package/extensions/tools/issues.ts +29 -0
  29. package/extensions/tools/milestones.ts +18 -0
  30. package/extensions/tools/project-labels.ts +17 -0
  31. package/extensions/tools/project-relations.ts +17 -0
  32. package/extensions/tools/projects.ts +25 -1
  33. package/extensions/tools/teams.ts +11 -3
  34. package/extensions/tools/users.ts +10 -0
  35. package/extensions/tools/workspaces.ts +6 -0
  36. package/package.json +1 -1
package/README.md CHANGED
@@ -1,6 +1,18 @@
1
1
  # pi-linear
2
2
 
3
- ![pi-linear screenshot](assets/screenshot.png)
3
+ <p align="center">
4
+ <img src="assets/screenshot.png" alt="pi-linear settings overlay" />
5
+ </p>
6
+
7
+ <p align="center"><em>Configure Linear tools and output defaults from the settings overlay.</em></p>
8
+
9
+ ---
10
+
11
+ <p align="center">
12
+ <img src="assets/linear_list_issues.png" alt="Human-readable Linear issue list" />
13
+ </p>
14
+
15
+ <p align="center"><em>Human-readable issue results keep screenshots clean while full JSON remains available with Ctrl+O.</em></p>
4
16
 
5
17
  Linear integration for [pi](https://pi.dev) with 55+ tools covering issues, projects, documents, initiatives, comments, relations, and more. Includes multi-workspace auth and a per-tool settings overlay.
6
18
 
@@ -46,7 +58,7 @@ Credentials are stored at `~/.pi/agent/state/extensions/linear/credentials.json`
46
58
 
47
59
  ## Tool settings
48
60
 
49
- Run `/linear-settings` to open an overlay where you can enable or disable tools by category or individually. Disabled tools are removed from the LLM's context entirely. Preferences persist across sessions.
61
+ Run `/linear-settings` to open an overlay where you can choose the default output view and enable or disable tools by category or individually. Linear results default to a human-readable view, with full JSON available via `Ctrl+O`; you can flip the default to full JSON if you prefer. Disabled tools are removed from the LLM's context entirely. Preferences persist across sessions.
50
62
 
51
63
  ## Tools (55)
52
64
 
Binary file
Binary file
@@ -210,5 +210,5 @@ export default async function linearExtension(pi: ExtensionAPI) {
210
210
  pi.registerTool(tool);
211
211
  }
212
212
 
213
- registerLinearSettings(pi);
213
+ await registerLinearSettings(pi);
214
214
  }
@@ -0,0 +1,323 @@
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
+ accentStyle,
9
+ asString,
10
+ cleanOneLine,
11
+ dimStyle,
12
+ expandedJson,
13
+ shouldShowJson,
14
+ jsonHint,
15
+ LinearListResultComponent,
16
+ mutedStyle,
17
+ renderLinearToolCall,
18
+ renderResponsiveTable,
19
+ toolOutputStyle,
20
+ truncate,
21
+ truncateLine,
22
+ type LinearToolRenderContext,
23
+ type TableColumn,
24
+ type ToolArgs,
25
+ } from './common';
26
+
27
+ type NamedRef = {
28
+ id?: string;
29
+ identifier?: string | null;
30
+ title?: string | null;
31
+ };
32
+
33
+ type CommentUser = {
34
+ id?: string;
35
+ name?: string | null;
36
+ email?: string | null;
37
+ };
38
+
39
+ type CommentLike = {
40
+ id?: string;
41
+ body?: string | null;
42
+ quotedText?: string | null;
43
+ createdAt?: string | null;
44
+ updatedAt?: string | null;
45
+ editedAt?: string | null;
46
+ resolvedAt?: string | null;
47
+ url?: string | null;
48
+ issue?: NamedRef | null;
49
+ parent?: { id?: string | null } | null;
50
+ user?: CommentUser | null;
51
+ };
52
+
53
+ type CommentResultDetails = {
54
+ comment?: CommentLike | null;
55
+ comments?: CommentLike[];
56
+ success?: boolean;
57
+ };
58
+
59
+ const COMMENT_LIST_PREVIEW_LIMIT = 20;
60
+ const BODY_LIMIT = 180;
61
+ const QUOTED_LIMIT = 90;
62
+ const ISSUE_TITLE_LIMIT = 80;
63
+ const TABLE_BODY_MIN_WIDTH = 24;
64
+
65
+ function commentDetails(result: AgentToolResult<any>): CommentResultDetails {
66
+ return (result.details ?? {}) as CommentResultDetails;
67
+ }
68
+
69
+ function argsObject(context: { args?: unknown }): ToolArgs {
70
+ return context.args && typeof context.args === 'object' && !Array.isArray(context.args)
71
+ ? (context.args as ToolArgs)
72
+ : {};
73
+ }
74
+
75
+ function dateText(value: unknown): string | undefined {
76
+ const date = asString(value);
77
+ if (!date) return undefined;
78
+ const [datePart] = date.split('T');
79
+ return datePart || date;
80
+ }
81
+
82
+ function issueIdentifier(comment: CommentLike): string | undefined {
83
+ return asString(comment.issue?.identifier) ?? asString(comment.issue?.id);
84
+ }
85
+
86
+ function issueTitle(comment: CommentLike): string | undefined {
87
+ const title = asString(comment.issue?.title);
88
+ return title ? truncate(cleanOneLine(title), ISSUE_TITLE_LIMIT) : undefined;
89
+ }
90
+
91
+ function issueText(comment: CommentLike): string | undefined {
92
+ const identifier = issueIdentifier(comment);
93
+ const title = issueTitle(comment);
94
+ if (identifier && title) return `${identifier} ${title}`;
95
+ return identifier ?? title;
96
+ }
97
+
98
+ function formatIssueText(comment: CommentLike, theme: Theme): string {
99
+ const identifier = issueIdentifier(comment);
100
+ const title = issueTitle(comment);
101
+ if (identifier && title)
102
+ return `${theme.fg('accent', identifier)} ${theme.fg('toolOutput', title)}`;
103
+ if (identifier) return theme.fg('accent', identifier);
104
+ if (title) return theme.fg('toolOutput', title);
105
+ return theme.fg('dim', 'No issue');
106
+ }
107
+
108
+ function authorText(comment: CommentLike): string | undefined {
109
+ return (
110
+ asString(comment.user?.name) ?? asString(comment.user?.email) ?? asString(comment.user?.id)
111
+ );
112
+ }
113
+
114
+ function bodySnippet(comment: CommentLike, limit = BODY_LIMIT): string | undefined {
115
+ const body = asString(comment.body);
116
+ if (!body) return undefined;
117
+ return truncate(cleanOneLine(body), limit);
118
+ }
119
+
120
+ function quotedSnippet(comment: CommentLike, limit = QUOTED_LIMIT): string | undefined {
121
+ const quoted = asString(comment.quotedText);
122
+ if (!quoted) return undefined;
123
+ return truncate(cleanOneLine(quoted), limit);
124
+ }
125
+
126
+ function bodyPreview(comment: CommentLike): string {
127
+ const body = bodySnippet(comment);
128
+ const quoted = quotedSnippet(comment);
129
+
130
+ if (body && quoted && body !== quoted) return truncate(`${body} — quoted: ${quoted}`, BODY_LIMIT);
131
+ return body ?? (quoted ? `quoted: ${quoted}` : '(empty comment)');
132
+ }
133
+
134
+ function listMetadataParts(comment: CommentLike): string[] {
135
+ const issue = issueText(comment);
136
+ const author = authorText(comment);
137
+ const updated = dateText(comment.updatedAt);
138
+
139
+ return [
140
+ issue ? `issue: ${issue}` : undefined,
141
+ author ? `author: ${author}` : undefined,
142
+ updated ? `updated: ${updated}` : undefined,
143
+ ].filter((part): part is string => !!part);
144
+ }
145
+
146
+ function cardMetadataParts(comment: CommentLike): string[] {
147
+ const author = authorText(comment);
148
+ const updated = dateText(comment.updatedAt);
149
+ const edited = dateText(comment.editedAt);
150
+ const resolved = dateText(comment.resolvedAt);
151
+
152
+ return [
153
+ author ? `author: ${author}` : undefined,
154
+ updated ? `updated: ${updated}` : undefined,
155
+ edited ? `edited: ${edited}` : undefined,
156
+ resolved ? `resolved: ${resolved}` : undefined,
157
+ ].filter((part): part is string => !!part);
158
+ }
159
+
160
+ function formatCommentListLine(comment: CommentLike, theme: Theme, width: number): string {
161
+ const body = bodyPreview(comment);
162
+ const metadata = listMetadataParts(comment);
163
+ const suffix = metadata.length ? theme.fg('dim', ` · ${metadata.join(' · ')}`) : '';
164
+
165
+ return truncateLine(` ${theme.fg('toolOutput', body)}${suffix}`, width);
166
+ }
167
+
168
+ const COMMENT_TABLE_COLUMNS: TableColumn<CommentLike>[] = [
169
+ {
170
+ id: 'issue',
171
+ label: 'Issue',
172
+ width: 16,
173
+ value: (comment) => issueText(comment) ?? '—',
174
+ style: (theme) => accentStyle(theme),
175
+ },
176
+ {
177
+ id: 'author',
178
+ label: 'Author',
179
+ width: 18,
180
+ value: (comment) => authorText(comment) ?? '—',
181
+ style: (theme) => mutedStyle(theme),
182
+ },
183
+ {
184
+ id: 'updated',
185
+ label: 'Updated',
186
+ width: 10,
187
+ value: (comment) => dateText(comment.updatedAt) ?? '—',
188
+ style: (theme) => dimStyle(theme),
189
+ },
190
+ ];
191
+
192
+ function renderCommentTable(comments: CommentLike[], theme: Theme, width: number): string[] {
193
+ return renderResponsiveTable(comments, theme, width, {
194
+ columns: COMMENT_TABLE_COLUMNS,
195
+ primary: {
196
+ label: 'Body',
197
+ minWidth: TABLE_BODY_MIN_WIDTH,
198
+ value: bodyPreview,
199
+ style: (theme) => toolOutputStyle(theme),
200
+ },
201
+ dropOrder: ['updated', 'author', 'issue'],
202
+ fallback: formatCommentListLine,
203
+ });
204
+ }
205
+
206
+ function renderCommentCard(
207
+ actionLabel: string,
208
+ comment: CommentLike | null | undefined,
209
+ theme: Theme,
210
+ ): Text {
211
+ if (!comment) {
212
+ return new Text(`\n${theme.fg('dim', 'Comment not found')}\n\n${jsonHint()}`, 0, 0);
213
+ }
214
+
215
+ const metadata = cardMetadataParts(comment);
216
+ const quoted = quotedSnippet(comment);
217
+ const body = bodySnippet(comment);
218
+ const url = asString(comment.url);
219
+
220
+ let text = `\n${theme.fg('success', `✓ ${actionLabel}`)} ${formatIssueText(comment, theme)}`;
221
+ if (metadata.length) text += `\n ${theme.fg('dim', metadata.join(' · '))}`;
222
+ if (quoted) text += `\n ${theme.fg('dim', `quoted: ${quoted}`)}`;
223
+ if (body) text += `\n ${theme.fg('muted', body)}`;
224
+ if (url) text += `\n ${theme.fg('dim', url)}`;
225
+ text += `\n\n${jsonHint()}`;
226
+
227
+ return new Text(text, 0, 0);
228
+ }
229
+
230
+ export function renderLinearCommentListCall(args: ToolArgs | undefined, theme: Theme): Text {
231
+ return renderLinearToolCall('linear_list_comments', args, theme, [
232
+ ['first', 'first'],
233
+ ['last', 'last'],
234
+ ['orderBy', 'order'],
235
+ ['includeArchived', 'archived'],
236
+ ['filter', 'filter'],
237
+ ]);
238
+ }
239
+
240
+ export function renderLinearCreateCommentCall(args: ToolArgs | undefined, theme: Theme): Text {
241
+ return renderLinearToolCall('linear_create_comment', args, theme, [
242
+ ['id', 'id'],
243
+ ['body', 'body'],
244
+ ['issueId', 'issueId'],
245
+ ['parentId', 'parentId'],
246
+ ['quotedText', 'quote'],
247
+ ]);
248
+ }
249
+
250
+ export function renderLinearUpdateCommentCall(args: ToolArgs | undefined, theme: Theme): Text {
251
+ return renderLinearToolCall('linear_update_comment', args, theme, [
252
+ ['id', 'id'],
253
+ ['body', 'body'],
254
+ ['quotedText', 'quote'],
255
+ ]);
256
+ }
257
+
258
+ export function renderLinearDeleteCommentCall(args: ToolArgs | undefined, theme: Theme): Text {
259
+ return renderLinearToolCall('linear_delete_comment', args, theme, [['id', 'id']]);
260
+ }
261
+
262
+ export function renderLinearCommentListResult(
263
+ result: AgentToolResult<any>,
264
+ options: ToolRenderResultOptions,
265
+ theme: Theme,
266
+ context: LinearToolRenderContext,
267
+ ): Text | LinearListResultComponent<CommentLike> {
268
+ if (options.isPartial) return new Text(theme.fg('warning', 'Loading comments…'), 0, 0);
269
+ if (shouldShowJson(options, context)) return expandedJson(result, theme);
270
+
271
+ const comments = Array.isArray(commentDetails(result).comments)
272
+ ? (commentDetails(result).comments as CommentLike[])
273
+ : [];
274
+
275
+ return new LinearListResultComponent(comments, theme, {
276
+ noun: 'comment',
277
+ emptyLabel: 'No comments found',
278
+ previewLimit: COMMENT_LIST_PREVIEW_LIMIT,
279
+ renderItems: renderCommentTable,
280
+ });
281
+ }
282
+
283
+ export function renderLinearCommentResult(actionLabel: string) {
284
+ return (
285
+ result: AgentToolResult<any>,
286
+ options: ToolRenderResultOptions,
287
+ theme: Theme,
288
+ context: LinearToolRenderContext,
289
+ ): Text => {
290
+ if (options.isPartial) return new Text(theme.fg('warning', `${actionLabel}…`), 0, 0);
291
+ if (shouldShowJson(options, context)) return expandedJson(result, theme);
292
+
293
+ return renderCommentCard(actionLabel, commentDetails(result).comment, theme);
294
+ };
295
+ }
296
+
297
+ export function renderLinearDeleteCommentResult(
298
+ result: AgentToolResult<any>,
299
+ options: ToolRenderResultOptions,
300
+ theme: Theme,
301
+ context: { args?: unknown },
302
+ ): Text {
303
+ if (options.isPartial) return new Text(theme.fg('warning', 'Deleting comment…'), 0, 0);
304
+ if (shouldShowJson(options, context)) return expandedJson(result, theme);
305
+
306
+ const details = commentDetails(result);
307
+ const args = argsObject(context);
308
+ const id = asString(args.id) ?? 'comment';
309
+
310
+ if (details.success !== true) {
311
+ return new Text(
312
+ `\n${theme.fg('warning', 'Deleted comment status unknown')}\n\n${jsonHint()}`,
313
+ 0,
314
+ 0,
315
+ );
316
+ }
317
+
318
+ return new Text(
319
+ `\n${theme.fg('success', '✓ Deleted comment')} ${theme.fg('accent', id)}\n\n${jsonHint()}`,
320
+ 0,
321
+ 0,
322
+ );
323
+ }
@@ -0,0 +1,305 @@
1
+ import {
2
+ keyHint,
3
+ type AgentToolResult,
4
+ type Theme,
5
+ type ToolRenderResultOptions,
6
+ } from '@mariozechner/pi-coding-agent';
7
+ import { Text, truncateToWidth, visibleWidth } from '@mariozechner/pi-tui';
8
+ import {
9
+ getDefaultJsonView,
10
+ registerLinearResultRenderer,
11
+ type LinearToolRenderContext,
12
+ } from './state';
13
+
14
+ export type { LinearToolRenderContext } from './state';
15
+
16
+ export type ToolArgs = Record<string, unknown>;
17
+ export type CellStyle = (text: string) => string;
18
+
19
+ export type TableColumn<T> = {
20
+ id: string;
21
+ label: string;
22
+ width: number;
23
+ value: (item: T) => string;
24
+ style?: (theme: Theme, value: string, item: T) => CellStyle;
25
+ };
26
+
27
+ export type PrimaryTableColumn<T> = {
28
+ label: string;
29
+ minWidth?: number;
30
+ value: (item: T) => string;
31
+ style?: (theme: Theme, value: string, item: T) => CellStyle;
32
+ };
33
+
34
+ export type ResponsiveTableOptions<T> = {
35
+ columns: TableColumn<T>[];
36
+ primary: PrimaryTableColumn<T>;
37
+ dropOrder?: string[];
38
+ fallback: (item: T, theme: Theme, width: number) => string;
39
+ minWidth?: number;
40
+ };
41
+
42
+ export type ToolCallField = [key: string, label: string];
43
+
44
+ const TABLE_SEPARATOR = ' ';
45
+ const DEFAULT_TABLE_MIN_WIDTH = 28;
46
+ const DEFAULT_PRIMARY_MIN_WIDTH = 24;
47
+ const FALLBACK_PRIMARY_MIN_WIDTH = 10;
48
+ const TOOL_ARG_STRING_LIMIT = 48;
49
+
50
+ export function asString(value: unknown): string | undefined {
51
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
52
+ }
53
+
54
+ export function cleanOneLine(value: string): string {
55
+ return value.replace(/\s+/g, ' ').trim();
56
+ }
57
+
58
+ export function truncate(value: string, maxLength: number): string {
59
+ if (value.length <= maxLength) return value;
60
+ return `${value.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
61
+ }
62
+
63
+ export function truncateLine(value: string, width: number): string {
64
+ return truncateToWidth(value, width);
65
+ }
66
+
67
+ export function textContent(result: AgentToolResult<any>): string {
68
+ const textBlock = result.content.find((block) => block.type === 'text');
69
+ if (textBlock?.type === 'text' && textBlock.text) return textBlock.text;
70
+ return JSON.stringify(result.details ?? null, null, 2);
71
+ }
72
+
73
+ export function expandedJson(result: AgentToolResult<any>, theme: Theme): Text {
74
+ const text = `\n${theme.fg('muted', 'Full JSON response')}\n${textContent(result)}\n\n${keyHint(
75
+ 'app.tools.expand',
76
+ 'show summary',
77
+ )}`;
78
+ return new Text(text, 0, 0);
79
+ }
80
+
81
+ export function shouldShowJson(
82
+ options: ToolRenderResultOptions,
83
+ context?: LinearToolRenderContext,
84
+ ): boolean {
85
+ registerLinearResultRenderer(context);
86
+ return options.expanded !== getDefaultJsonView();
87
+ }
88
+
89
+ export function jsonHint(): string {
90
+ return `(${keyHint('app.tools.expand', 'show full JSON')})`;
91
+ }
92
+
93
+ export function plural(count: number, singular: string, pluralForm = `${singular}s`): string {
94
+ return `${count} ${count === 1 ? singular : pluralForm}`;
95
+ }
96
+
97
+ function truncatePlainToWidth(value: string, width: number, ellipsis = '...'): string {
98
+ if (width <= 0) return '';
99
+ if (visibleWidth(value) <= width) return value;
100
+
101
+ const ellipsisWidth = visibleWidth(ellipsis);
102
+ if (ellipsisWidth >= width) return ellipsis.slice(0, width);
103
+
104
+ const targetWidth = width - ellipsisWidth;
105
+ let output = '';
106
+ let outputWidth = 0;
107
+
108
+ for (const character of Array.from(value)) {
109
+ const characterWidth = visibleWidth(character);
110
+ if (outputWidth + characterWidth > targetWidth) break;
111
+ output += character;
112
+ outputWidth += characterWidth;
113
+ }
114
+
115
+ return `${output.trimEnd()}${ellipsis}`;
116
+ }
117
+
118
+ export function formatCell(rawValue: string, width: number, style: CellStyle): string {
119
+ const cleanValue = cleanOneLine(rawValue || '—');
120
+ const truncated = truncatePlainToWidth(cleanValue, width);
121
+ const padding = ' '.repeat(Math.max(0, width - visibleWidth(truncated)));
122
+ return `${style(truncated)}${padding}`;
123
+ }
124
+
125
+ export function accentStyle(theme: Theme): CellStyle {
126
+ return (text) => theme.fg('accent', text);
127
+ }
128
+
129
+ export function dimStyle(theme: Theme): CellStyle {
130
+ return (text) => theme.fg('dim', text);
131
+ }
132
+
133
+ export function mutedStyle(theme: Theme): CellStyle {
134
+ return (text) => theme.fg('muted', text);
135
+ }
136
+
137
+ export function toolOutputStyle(theme: Theme): CellStyle {
138
+ return (text) => theme.fg('toolOutput', text);
139
+ }
140
+
141
+ function fitTableLayout<T>(
142
+ width: number,
143
+ columns: TableColumn<T>[],
144
+ primaryMinWidth: number,
145
+ dropOrder?: string[],
146
+ minWidth = DEFAULT_TABLE_MIN_WIDTH,
147
+ ): { columns: TableColumn<T>[]; primaryWidth: number } | undefined {
148
+ if (width < minWidth) return undefined;
149
+
150
+ const idsToDrop = dropOrder ?? columns.map((column) => column.id).reverse();
151
+ let visibleColumns = [...columns];
152
+
153
+ const primaryWidthFor = (candidateColumns: TableColumn<T>[]) => {
154
+ const separatorWidth = TABLE_SEPARATOR.length * candidateColumns.length;
155
+ const fixedWidth = candidateColumns.reduce((sum, column) => sum + column.width, 0);
156
+ return width - fixedWidth - separatorWidth;
157
+ };
158
+
159
+ let primaryWidth = primaryWidthFor(visibleColumns);
160
+ for (const columnToDrop of idsToDrop) {
161
+ if (primaryWidth >= primaryMinWidth) break;
162
+ visibleColumns = visibleColumns.filter((column) => column.id !== columnToDrop);
163
+ primaryWidth = primaryWidthFor(visibleColumns);
164
+ }
165
+
166
+ if (primaryWidth < FALLBACK_PRIMARY_MIN_WIDTH) return undefined;
167
+ return { columns: visibleColumns, primaryWidth };
168
+ }
169
+
170
+ function tableLine(cells: string[], width: number): string {
171
+ return truncateToWidth(cells.join(TABLE_SEPARATOR), width);
172
+ }
173
+
174
+ export function renderResponsiveTable<T>(
175
+ items: T[],
176
+ theme: Theme,
177
+ width: number,
178
+ options: ResponsiveTableOptions<T>,
179
+ ): string[] {
180
+ const layout = fitTableLayout(
181
+ width,
182
+ options.columns,
183
+ options.primary.minWidth ?? DEFAULT_PRIMARY_MIN_WIDTH,
184
+ options.dropOrder,
185
+ options.minWidth,
186
+ );
187
+
188
+ if (!layout) {
189
+ return items.map((item) => options.fallback(item, theme, width));
190
+ }
191
+
192
+ const headerCells = [
193
+ ...layout.columns.map((column) =>
194
+ formatCell(column.label, column.width, (text) => theme.fg('dim', text)),
195
+ ),
196
+ formatCell(options.primary.label, layout.primaryWidth, (text) => theme.fg('dim', text)),
197
+ ];
198
+
199
+ const lines = [tableLine(headerCells, width)];
200
+ for (const item of items) {
201
+ const cells = [
202
+ ...layout.columns.map((column) => {
203
+ const value = column.value(item);
204
+ const style = column.style?.(theme, value, item) ?? mutedStyle(theme);
205
+ return formatCell(value, column.width, style);
206
+ }),
207
+ (() => {
208
+ const value = options.primary.value(item);
209
+ const style = options.primary.style?.(theme, value, item) ?? toolOutputStyle(theme);
210
+ return formatCell(value, layout.primaryWidth, style);
211
+ })(),
212
+ ];
213
+ lines.push(tableLine(cells, width));
214
+ }
215
+
216
+ return lines;
217
+ }
218
+
219
+ export class LinearListResultComponent<T> {
220
+ constructor(
221
+ private readonly items: T[],
222
+ private readonly theme: Theme,
223
+ private readonly options: {
224
+ noun: string;
225
+ pluralNoun?: string;
226
+ emptyLabel: string;
227
+ previewLimit?: number;
228
+ renderItems: (items: T[], theme: Theme, width: number) => string[];
229
+ },
230
+ ) {}
231
+
232
+ render(width: number): string[] {
233
+ const lines: string[] = [''];
234
+
235
+ if (this.items.length === 0) {
236
+ lines.push(this.theme.fg('dim', this.options.emptyLabel));
237
+ lines.push('');
238
+ lines.push(jsonHint());
239
+ return lines.map((line) => truncateToWidth(line, width));
240
+ }
241
+
242
+ const previewLimit = this.options.previewLimit ?? 20;
243
+ const shown = this.items.slice(0, previewLimit);
244
+ lines.push(
245
+ this.theme.fg(
246
+ 'success',
247
+ `✓ ${plural(this.items.length, this.options.noun, this.options.pluralNoun)} returned`,
248
+ ),
249
+ );
250
+ lines.push('');
251
+ lines.push(...this.options.renderItems(shown, this.theme, width));
252
+
253
+ if (shown.length < this.items.length) {
254
+ lines.push(
255
+ this.theme.fg(
256
+ 'dim',
257
+ `… ${plural(this.items.length - shown.length, `more ${this.options.noun}`)}`,
258
+ ),
259
+ );
260
+ }
261
+
262
+ lines.push('');
263
+ lines.push(jsonHint());
264
+
265
+ return lines.map((line) => truncateToWidth(line, width));
266
+ }
267
+
268
+ invalidate(): void {}
269
+ }
270
+
271
+ export function formatToolArgValue(value: unknown): string | undefined {
272
+ if (typeof value === 'string') {
273
+ const trimmed = value.trim();
274
+ if (!trimmed) return undefined;
275
+ return trimmed.includes(' ')
276
+ ? `"${truncate(trimmed, TOOL_ARG_STRING_LIMIT)}"`
277
+ : truncate(trimmed, TOOL_ARG_STRING_LIMIT);
278
+ }
279
+ if (typeof value === 'number') return String(value);
280
+ if (typeof value === 'boolean') return value ? 'true' : undefined;
281
+ if (Array.isArray(value)) return value.length ? `[${value.length}]` : undefined;
282
+ if (value && typeof value === 'object') return '{…}';
283
+ return undefined;
284
+ }
285
+
286
+ export function renderLinearToolCall(
287
+ toolName: string,
288
+ args: ToolArgs | undefined,
289
+ theme: Theme,
290
+ fields: ToolCallField[],
291
+ ): Text {
292
+ let text = theme.fg('toolTitle', theme.bold(toolName));
293
+ const parts = fields
294
+ .map(([key, label]) => {
295
+ const value = formatToolArgValue(args?.[key]);
296
+ return value ? `${label}=${value}` : undefined;
297
+ })
298
+ .filter((part): part is string => !!part);
299
+
300
+ if (parts.length) {
301
+ text += ` ${theme.fg('dim', parts.join(' '))}`;
302
+ }
303
+
304
+ return new Text(text, 0, 0);
305
+ }