@alasano/pi-linear 0.1.1 → 0.3.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 (38) hide show
  1. package/README.md +16 -2
  2. package/assets/linear_list_issues.png +0 -0
  3. package/assets/screenshot.png +0 -0
  4. package/extensions/params.ts +40 -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 +437 -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/selections.ts +10 -3
  22. package/extensions/settings.ts +40 -7
  23. package/extensions/tools/comments.ts +30 -11
  24. package/extensions/tools/documents.ts +42 -11
  25. package/extensions/tools/initiatives.ts +43 -11
  26. package/extensions/tools/issue-labels.ts +36 -11
  27. package/extensions/tools/issue-relations.ts +32 -13
  28. package/extensions/tools/issue-statuses.ts +19 -11
  29. package/extensions/tools/issues.ts +53 -19
  30. package/extensions/tools/milestones.ts +31 -11
  31. package/extensions/tools/project-labels.ts +30 -11
  32. package/extensions/tools/project-relations.ts +32 -13
  33. package/extensions/tools/projects.ts +48 -16
  34. package/extensions/tools/teams.ts +23 -11
  35. package/extensions/tools/users.ts +23 -11
  36. package/extensions/tools/workspaces.ts +6 -0
  37. package/extensions/types.ts +12 -0
  38. package/package.json +1 -1
@@ -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
+ }
@@ -0,0 +1,326 @@
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
+ key?: string | null;
31
+ name?: string | null;
32
+ title?: string | null;
33
+ };
34
+
35
+ type DocumentLike = {
36
+ id?: string;
37
+ title?: string | null;
38
+ content?: string | null;
39
+ summary?: string | null;
40
+ hiddenAt?: string | null;
41
+ archivedAt?: string | null;
42
+ trashed?: boolean | null;
43
+ updatedAt?: string | null;
44
+ url?: string | null;
45
+ team?: NamedRef | null;
46
+ project?: NamedRef | null;
47
+ issue?: NamedRef | null;
48
+ initiative?: NamedRef | null;
49
+ };
50
+
51
+ type DocumentResultDetails = {
52
+ document?: DocumentLike | null;
53
+ documents?: DocumentLike[];
54
+ success?: boolean;
55
+ };
56
+
57
+ const DOCUMENT_LIST_PREVIEW_LIMIT = 20;
58
+ const TITLE_LIMIT = 90;
59
+ const SNIPPET_LIMIT = 180;
60
+ const TABLE_TITLE_MIN_WIDTH = 24;
61
+
62
+ function documentDetails(result: AgentToolResult<any>): DocumentResultDetails {
63
+ return (result.details ?? {}) as DocumentResultDetails;
64
+ }
65
+
66
+ function documentTitle(document: DocumentLike): string {
67
+ return truncate(cleanOneLine(asString(document.title) ?? '(untitled)'), TITLE_LIMIT);
68
+ }
69
+
70
+ function contextText(document: DocumentLike): string | undefined {
71
+ const issue = document.issue;
72
+ if (issue) {
73
+ return asString(issue.identifier) ?? asString(issue.title) ?? asString(issue.id);
74
+ }
75
+
76
+ const project = document.project;
77
+ if (project) return asString(project.name) ?? asString(project.id);
78
+
79
+ const initiative = document.initiative;
80
+ if (initiative) return asString(initiative.name) ?? asString(initiative.id);
81
+
82
+ return undefined;
83
+ }
84
+
85
+ function teamText(document: DocumentLike): string | undefined {
86
+ const team = document.team;
87
+ if (!team) return undefined;
88
+ return asString(team.key) ?? asString(team.name) ?? asString(team.id);
89
+ }
90
+
91
+ function dateText(value: unknown): string | undefined {
92
+ const date = asString(value);
93
+ if (!date) return undefined;
94
+ return date.includes('T') ? date.split('T')[0] : date;
95
+ }
96
+
97
+ function documentFlags(document: DocumentLike): string[] {
98
+ return [
99
+ asString(document.hiddenAt) ? 'hidden' : undefined,
100
+ asString(document.archivedAt) ? 'archived' : undefined,
101
+ document.trashed === true ? 'trashed' : undefined,
102
+ ].filter((flag): flag is string => !!flag);
103
+ }
104
+
105
+ function flagsText(document: DocumentLike): string {
106
+ return documentFlags(document).join(', ') || '—';
107
+ }
108
+
109
+ function documentSnippet(document: DocumentLike): string | undefined {
110
+ const summary = asString(document.summary);
111
+ if (summary) return truncate(cleanOneLine(summary), SNIPPET_LIMIT);
112
+
113
+ const content = asString(document.content);
114
+ if (content) return truncate(cleanOneLine(content), SNIPPET_LIMIT);
115
+
116
+ return undefined;
117
+ }
118
+
119
+ function formatDocumentTitle(document: DocumentLike, theme: Theme): string {
120
+ return theme.fg('toolOutput', documentTitle(document));
121
+ }
122
+
123
+ function metadataParts(document: DocumentLike): string[] {
124
+ const context = contextText(document);
125
+ const team = teamText(document);
126
+ const updated = dateText(document.updatedAt);
127
+ const flags = flagsText(document);
128
+
129
+ return [
130
+ context ? `context: ${context}` : undefined,
131
+ team ? `team: ${team}` : undefined,
132
+ updated ? `updated: ${updated}` : undefined,
133
+ flags !== '—' ? flags : undefined,
134
+ ].filter((part): part is string => !!part);
135
+ }
136
+
137
+ function formatDocumentListLine(document: DocumentLike, theme: Theme, width: number): string {
138
+ const title = documentTitle(document);
139
+ const metadata = metadataParts(document);
140
+ const suffix = metadata.length ? theme.fg('dim', ` · ${metadata.join(' · ')}`) : '';
141
+
142
+ return truncateLine(` ${theme.fg('toolOutput', title)}${suffix}`, width);
143
+ }
144
+
145
+ function flagsStyle(theme: Theme, value: string): (text: string) => string {
146
+ if (value === '—') return dimStyle(theme);
147
+ return (text) => theme.fg('warning', text);
148
+ }
149
+
150
+ const DOCUMENT_TABLE_COLUMNS: TableColumn<DocumentLike>[] = [
151
+ {
152
+ id: 'context',
153
+ label: 'Context',
154
+ width: 18,
155
+ value: (document) => contextText(document) ?? '—',
156
+ style: (theme) => accentStyle(theme),
157
+ },
158
+ {
159
+ id: 'team',
160
+ label: 'Team',
161
+ width: 12,
162
+ value: (document) => teamText(document) ?? '—',
163
+ style: (theme) => mutedStyle(theme),
164
+ },
165
+ {
166
+ id: 'updated',
167
+ label: 'Updated',
168
+ width: 10,
169
+ value: (document) => dateText(document.updatedAt) ?? '—',
170
+ style: (theme) => dimStyle(theme),
171
+ },
172
+ {
173
+ id: 'flags',
174
+ label: 'Flags',
175
+ width: 18,
176
+ value: flagsText,
177
+ style: flagsStyle,
178
+ },
179
+ ];
180
+
181
+ function renderDocumentTable(documents: DocumentLike[], theme: Theme, width: number): string[] {
182
+ return renderResponsiveTable(documents, theme, width, {
183
+ columns: DOCUMENT_TABLE_COLUMNS,
184
+ primary: {
185
+ label: 'Title',
186
+ minWidth: TABLE_TITLE_MIN_WIDTH,
187
+ value: documentTitle,
188
+ style: (theme) => toolOutputStyle(theme),
189
+ },
190
+ dropOrder: ['flags', 'updated', 'team', 'context'],
191
+ fallback: formatDocumentListLine,
192
+ });
193
+ }
194
+
195
+ export function renderLinearDocumentListCall(args: ToolArgs | undefined, theme: Theme): Text {
196
+ return renderLinearToolCall('linear_list_documents', args, theme, [
197
+ ['first', 'first'],
198
+ ['last', 'last'],
199
+ ['orderBy', 'order'],
200
+ ['includeArchived', 'archived'],
201
+ ['filter', 'filter'],
202
+ ]);
203
+ }
204
+
205
+ export function renderLinearGetDocumentCall(args: ToolArgs | undefined, theme: Theme): Text {
206
+ return renderLinearToolCall('linear_get_document', args, theme, [['documentId', 'documentId']]);
207
+ }
208
+
209
+ export function renderLinearCreateDocumentCall(args: ToolArgs | undefined, theme: Theme): Text {
210
+ return renderLinearToolCall('linear_create_document', args, theme, [
211
+ ['title', 'title'],
212
+ ['teamKey', 'team'],
213
+ ['teamId', 'teamId'],
214
+ ['projectId', 'projectId'],
215
+ ['issueId', 'issueId'],
216
+ ['initiativeId', 'initiativeId'],
217
+ ]);
218
+ }
219
+
220
+ export function renderLinearUpdateDocumentCall(args: ToolArgs | undefined, theme: Theme): Text {
221
+ return renderLinearToolCall('linear_update_document', args, theme, [
222
+ ['documentId', 'documentId'],
223
+ ['title', 'title'],
224
+ ['teamKey', 'team'],
225
+ ['teamId', 'teamId'],
226
+ ['projectId', 'projectId'],
227
+ ['issueId', 'issueId'],
228
+ ['initiativeId', 'initiativeId'],
229
+ ]);
230
+ }
231
+
232
+ export function renderLinearDeleteDocumentCall(args: ToolArgs | undefined, theme: Theme): Text {
233
+ return renderLinearToolCall('linear_delete_document', args, theme, [
234
+ ['documentId', 'documentId'],
235
+ ]);
236
+ }
237
+
238
+ export function renderLinearUnarchiveDocumentCall(args: ToolArgs | undefined, theme: Theme): Text {
239
+ return renderLinearToolCall('linear_unarchive_document', args, theme, [
240
+ ['documentId', 'documentId'],
241
+ ]);
242
+ }
243
+
244
+ export function renderLinearDocumentListResult(
245
+ result: AgentToolResult<any>,
246
+ options: ToolRenderResultOptions,
247
+ theme: Theme,
248
+ context: LinearToolRenderContext,
249
+ ): Text | LinearListResultComponent<DocumentLike> {
250
+ if (options.isPartial) return new Text(theme.fg('warning', 'Loading documents…'), 0, 0);
251
+ if (shouldShowJson(options, context)) return expandedJson(result, theme);
252
+
253
+ const documents = Array.isArray(documentDetails(result).documents)
254
+ ? (documentDetails(result).documents as DocumentLike[])
255
+ : [];
256
+
257
+ return new LinearListResultComponent(documents, theme, {
258
+ noun: 'document',
259
+ emptyLabel: 'No documents found',
260
+ previewLimit: DOCUMENT_LIST_PREVIEW_LIMIT,
261
+ renderItems: renderDocumentTable,
262
+ });
263
+ }
264
+
265
+ export function renderLinearDocumentResult(actionLabel: string) {
266
+ return (
267
+ result: AgentToolResult<any>,
268
+ options: ToolRenderResultOptions,
269
+ theme: Theme,
270
+ context: LinearToolRenderContext,
271
+ ): Text => {
272
+ if (options.isPartial) return new Text(theme.fg('warning', `${actionLabel}…`), 0, 0);
273
+ if (shouldShowJson(options, context)) return expandedJson(result, theme);
274
+
275
+ const document = documentDetails(result).document;
276
+ if (!document) {
277
+ return new Text(`\n${theme.fg('dim', 'Document not found')}\n\n${jsonHint()}`, 0, 0);
278
+ }
279
+
280
+ const metadata = metadataParts(document);
281
+ const snippet = documentSnippet(document);
282
+ const url = asString(document.url);
283
+
284
+ let text = `\n${theme.fg('success', `✓ ${actionLabel}`)} ${formatDocumentTitle(document, theme)}`;
285
+ if (metadata.length) text += `\n ${theme.fg('dim', metadata.join(' · '))}`;
286
+ if (url) text += `\n ${theme.fg('dim', url)}`;
287
+ if (snippet) text += `\n ${theme.fg('muted', snippet)}`;
288
+ text += `\n\n${jsonHint()}`;
289
+
290
+ return new Text(text, 0, 0);
291
+ };
292
+ }
293
+
294
+ export function renderLinearDocumentSuccessResult(defaultActionLabel: string) {
295
+ return (
296
+ result: AgentToolResult<any>,
297
+ options: ToolRenderResultOptions,
298
+ theme: Theme,
299
+ context: { args?: unknown },
300
+ ): Text => {
301
+ if (options.isPartial)
302
+ return new Text(theme.fg('warning', `${defaultActionLabel} document…`), 0, 0);
303
+ if (shouldShowJson(options, context)) return expandedJson(result, theme);
304
+
305
+ const details = documentDetails(result);
306
+ const args = (context.args ?? {}) as ToolArgs;
307
+ const documentId = asString(args.documentId) ?? 'document';
308
+
309
+ if (details.success !== true) {
310
+ return new Text(
311
+ `\n${theme.fg('warning', `${defaultActionLabel} status unknown`)}\n\n${jsonHint()}`,
312
+ 0,
313
+ 0,
314
+ );
315
+ }
316
+
317
+ return new Text(
318
+ `\n${theme.fg('success', `✓ ${defaultActionLabel}`)} ${theme.fg(
319
+ 'accent',
320
+ documentId,
321
+ )}\n\n${jsonHint()}`,
322
+ 0,
323
+ 0,
324
+ );
325
+ };
326
+ }