opencode-manager 0.3.1 → 0.4.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/PROJECT-SUMMARY.md +104 -24
- package/README.md +335 -7
- package/bun.lock +17 -1
- package/manage_opencode_projects.py +71 -66
- package/package.json +6 -3
- package/src/bin/opencode-manager.ts +133 -3
- package/src/cli/backup.ts +324 -0
- package/src/cli/commands/chat.ts +322 -0
- package/src/cli/commands/projects.ts +222 -0
- package/src/cli/commands/sessions.ts +495 -0
- package/src/cli/commands/tokens.ts +168 -0
- package/src/cli/commands/tui.ts +36 -0
- package/src/cli/errors.ts +259 -0
- package/src/cli/formatters/json.ts +184 -0
- package/src/cli/formatters/ndjson.ts +71 -0
- package/src/cli/formatters/table.ts +837 -0
- package/src/cli/index.ts +169 -0
- package/src/cli/output.ts +661 -0
- package/src/cli/resolvers.ts +249 -0
- package/src/lib/clipboard.ts +37 -0
- package/src/lib/opencode-data.ts +380 -1
- package/src/lib/search.ts +170 -0
- package/src/{opencode-tui.tsx → tui/app.tsx} +739 -105
- package/src/tui/args.ts +92 -0
- package/src/tui/index.tsx +46 -0
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table output formatter for CLI commands.
|
|
3
|
+
*
|
|
4
|
+
* Provides human-readable table output for terminal display.
|
|
5
|
+
* Supports column definitions, truncation, and alignment.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { AggregateTokenSummary, ChatMessage, ChatRole, ChatSearchResult, ProjectRecord, ProjectState, SessionRecord, TokenBreakdown, TokenSummary } from "../../lib/opencode-data"
|
|
9
|
+
|
|
10
|
+
// ========================
|
|
11
|
+
// Column Definition Types
|
|
12
|
+
// ========================
|
|
13
|
+
|
|
14
|
+
export type Alignment = "left" | "right" | "center"
|
|
15
|
+
|
|
16
|
+
export interface ColumnDefinition<T, V = string | number | Date | null | undefined> {
|
|
17
|
+
/** Column header label */
|
|
18
|
+
header: string
|
|
19
|
+
/** Width of the column (characters) */
|
|
20
|
+
width: number
|
|
21
|
+
/** Text alignment */
|
|
22
|
+
align?: Alignment
|
|
23
|
+
/** Function to extract the cell value from the row data */
|
|
24
|
+
accessor: (row: T) => V
|
|
25
|
+
/** Optional function to format the value for display (required for non-primitive types) */
|
|
26
|
+
format?: (value: V) => string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface TableFormatOptions {
|
|
30
|
+
/** Character used to separate columns (default: " ") */
|
|
31
|
+
separator?: string
|
|
32
|
+
/** Character used for header underline (default: "-") */
|
|
33
|
+
headerUnderline?: string
|
|
34
|
+
/** Whether to show header underline (default: true) */
|
|
35
|
+
showUnderline?: boolean
|
|
36
|
+
/** Whether to show headers (default: true) */
|
|
37
|
+
showHeaders?: boolean
|
|
38
|
+
/** Truncation suffix when text is too long (default: "…") */
|
|
39
|
+
truncateSuffix?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ========================
|
|
43
|
+
// Core Table Utilities
|
|
44
|
+
// ========================
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Truncate a string to fit within a specified width.
|
|
48
|
+
*/
|
|
49
|
+
export function truncate(text: string, width: number, suffix = "…"): string {
|
|
50
|
+
if (text.length <= width) {
|
|
51
|
+
return text
|
|
52
|
+
}
|
|
53
|
+
if (width <= suffix.length) {
|
|
54
|
+
return suffix.slice(0, width)
|
|
55
|
+
}
|
|
56
|
+
return text.slice(0, width - suffix.length) + suffix
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Pad a string to a specified width with alignment.
|
|
61
|
+
*/
|
|
62
|
+
export function pad(text: string, width: number, align: Alignment = "left"): string {
|
|
63
|
+
if (text.length >= width) {
|
|
64
|
+
return text
|
|
65
|
+
}
|
|
66
|
+
const padding = width - text.length
|
|
67
|
+
switch (align) {
|
|
68
|
+
case "right":
|
|
69
|
+
return " ".repeat(padding) + text
|
|
70
|
+
case "center": {
|
|
71
|
+
const left = Math.floor(padding / 2)
|
|
72
|
+
const right = padding - left
|
|
73
|
+
return " ".repeat(left) + text + " ".repeat(right)
|
|
74
|
+
}
|
|
75
|
+
case "left":
|
|
76
|
+
default:
|
|
77
|
+
return text + " ".repeat(padding)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Format a cell value, truncating and padding as needed.
|
|
83
|
+
*/
|
|
84
|
+
export function formatCell(
|
|
85
|
+
value: string | number | null | undefined,
|
|
86
|
+
width: number,
|
|
87
|
+
align: Alignment = "left",
|
|
88
|
+
truncateSuffix = "…"
|
|
89
|
+
): string {
|
|
90
|
+
const text = value == null ? "" : String(value)
|
|
91
|
+
const truncated = truncate(text, width, truncateSuffix)
|
|
92
|
+
return pad(truncated, width, align)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Format a row of data into a table line.
|
|
97
|
+
*/
|
|
98
|
+
export function formatRow<T>(
|
|
99
|
+
row: T,
|
|
100
|
+
columns: ColumnDefinition<T>[],
|
|
101
|
+
options?: TableFormatOptions
|
|
102
|
+
): string {
|
|
103
|
+
const separator = options?.separator ?? " "
|
|
104
|
+
const truncateSuffix = options?.truncateSuffix ?? "…"
|
|
105
|
+
|
|
106
|
+
return columns
|
|
107
|
+
.map((col) => {
|
|
108
|
+
const raw = col.accessor(row)
|
|
109
|
+
// If format function is provided, use it; otherwise convert to string
|
|
110
|
+
let formatted: string | number | null | undefined
|
|
111
|
+
if (col.format) {
|
|
112
|
+
formatted = col.format(raw)
|
|
113
|
+
} else if (raw instanceof Date) {
|
|
114
|
+
formatted = raw.toISOString()
|
|
115
|
+
} else {
|
|
116
|
+
formatted = raw as string | number | null | undefined
|
|
117
|
+
}
|
|
118
|
+
return formatCell(formatted, col.width, col.align ?? "left", truncateSuffix)
|
|
119
|
+
})
|
|
120
|
+
.join(separator)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Format the header row.
|
|
125
|
+
*/
|
|
126
|
+
export function formatHeader<T>(
|
|
127
|
+
columns: ColumnDefinition<T>[],
|
|
128
|
+
options?: TableFormatOptions
|
|
129
|
+
): string {
|
|
130
|
+
const separator = options?.separator ?? " "
|
|
131
|
+
return columns
|
|
132
|
+
.map((col) => pad(col.header, col.width, col.align ?? "left"))
|
|
133
|
+
.join(separator)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Format the header underline.
|
|
138
|
+
*/
|
|
139
|
+
export function formatHeaderUnderline<T>(
|
|
140
|
+
columns: ColumnDefinition<T>[],
|
|
141
|
+
options?: TableFormatOptions
|
|
142
|
+
): string {
|
|
143
|
+
const separator = options?.separator ?? " "
|
|
144
|
+
const underlineChar = options?.headerUnderline ?? "-"
|
|
145
|
+
return columns
|
|
146
|
+
.map((col) => underlineChar.repeat(col.width))
|
|
147
|
+
.join(separator)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Format a table from an array of records.
|
|
152
|
+
*/
|
|
153
|
+
export function formatTable<T>(
|
|
154
|
+
data: T[],
|
|
155
|
+
columns: ColumnDefinition<T>[],
|
|
156
|
+
options?: TableFormatOptions
|
|
157
|
+
): string {
|
|
158
|
+
const showHeaders = options?.showHeaders ?? true
|
|
159
|
+
const showUnderline = options?.showUnderline ?? true
|
|
160
|
+
|
|
161
|
+
const lines: string[] = []
|
|
162
|
+
|
|
163
|
+
if (showHeaders) {
|
|
164
|
+
lines.push(formatHeader(columns, options))
|
|
165
|
+
if (showUnderline) {
|
|
166
|
+
lines.push(formatHeaderUnderline(columns, options))
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const row of data) {
|
|
171
|
+
lines.push(formatRow(row, columns, options))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return lines.join("\n")
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Print a table to stdout.
|
|
179
|
+
*/
|
|
180
|
+
export function printTable<T>(
|
|
181
|
+
data: T[],
|
|
182
|
+
columns: ColumnDefinition<T>[],
|
|
183
|
+
options?: TableFormatOptions
|
|
184
|
+
): void {
|
|
185
|
+
console.log(formatTable(data, columns, options))
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ========================
|
|
189
|
+
// Date/Time Formatting
|
|
190
|
+
// ========================
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Format a date for table display.
|
|
194
|
+
* Uses ISO format but truncated for readability.
|
|
195
|
+
*/
|
|
196
|
+
export function formatDateForTable(date: Date | null | undefined): string {
|
|
197
|
+
if (!date) {
|
|
198
|
+
return "-"
|
|
199
|
+
}
|
|
200
|
+
// Format: YYYY-MM-DD HH:MM
|
|
201
|
+
const iso = date.toISOString()
|
|
202
|
+
return iso.slice(0, 16).replace("T", " ")
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ========================
|
|
206
|
+
// Projects List Columns
|
|
207
|
+
// ========================
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Format project state with visual indicator.
|
|
211
|
+
*/
|
|
212
|
+
export function formatProjectState(state: ProjectState): string {
|
|
213
|
+
switch (state) {
|
|
214
|
+
case "present":
|
|
215
|
+
return "✓"
|
|
216
|
+
case "missing":
|
|
217
|
+
return "✗"
|
|
218
|
+
case "unknown":
|
|
219
|
+
return "?"
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Column definitions for projects list output.
|
|
225
|
+
*
|
|
226
|
+
* Columns: #, State, Path, ProjectID, Created
|
|
227
|
+
*/
|
|
228
|
+
export const projectListColumns: ColumnDefinition<ProjectRecord>[] = [
|
|
229
|
+
{
|
|
230
|
+
header: "#",
|
|
231
|
+
width: 4,
|
|
232
|
+
align: "right",
|
|
233
|
+
accessor: (row) => row.index,
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
header: "State",
|
|
237
|
+
width: 5,
|
|
238
|
+
align: "center",
|
|
239
|
+
accessor: (row) => row.state,
|
|
240
|
+
format: (state) => formatProjectState(state as ProjectState),
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
header: "Path",
|
|
244
|
+
width: 50,
|
|
245
|
+
align: "left",
|
|
246
|
+
accessor: (row) => row.worktree,
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
header: "Project ID",
|
|
250
|
+
width: 24,
|
|
251
|
+
align: "left",
|
|
252
|
+
accessor: (row) => row.projectId,
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
header: "Created",
|
|
256
|
+
width: 16,
|
|
257
|
+
align: "left",
|
|
258
|
+
accessor: (row) => row.createdAt,
|
|
259
|
+
format: (val) => formatDateForTable(val as Date | null | undefined),
|
|
260
|
+
},
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Compact column definitions for projects list (narrower terminals).
|
|
265
|
+
*/
|
|
266
|
+
export const projectListColumnsCompact: ColumnDefinition<ProjectRecord>[] = [
|
|
267
|
+
{
|
|
268
|
+
header: "#",
|
|
269
|
+
width: 4,
|
|
270
|
+
align: "right",
|
|
271
|
+
accessor: (row) => row.index,
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
header: "St",
|
|
275
|
+
width: 2,
|
|
276
|
+
align: "center",
|
|
277
|
+
accessor: (row) => row.state,
|
|
278
|
+
format: (state) => formatProjectState(state as ProjectState),
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
header: "Path",
|
|
282
|
+
width: 40,
|
|
283
|
+
align: "left",
|
|
284
|
+
accessor: (row) => row.worktree,
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
header: "Project ID",
|
|
288
|
+
width: 20,
|
|
289
|
+
align: "left",
|
|
290
|
+
accessor: (row) => row.projectId,
|
|
291
|
+
},
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Format a projects list as a table.
|
|
296
|
+
*/
|
|
297
|
+
export function formatProjectsTable(
|
|
298
|
+
projects: ProjectRecord[],
|
|
299
|
+
options?: TableFormatOptions & { compact?: boolean }
|
|
300
|
+
): string {
|
|
301
|
+
const columns = options?.compact ? projectListColumnsCompact : projectListColumns
|
|
302
|
+
return formatTable(projects, columns, options)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Print a projects list table to stdout.
|
|
307
|
+
*/
|
|
308
|
+
export function printProjectsTable(
|
|
309
|
+
projects: ProjectRecord[],
|
|
310
|
+
options?: TableFormatOptions & { compact?: boolean }
|
|
311
|
+
): void {
|
|
312
|
+
console.log(formatProjectsTable(projects, options))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ========================
|
|
316
|
+
// Sessions List Columns
|
|
317
|
+
// ========================
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Column definitions for sessions list output.
|
|
321
|
+
*
|
|
322
|
+
* Columns: #, Title, SessionID, ProjectID, Updated, Created
|
|
323
|
+
*/
|
|
324
|
+
export const sessionListColumns: ColumnDefinition<SessionRecord>[] = [
|
|
325
|
+
{
|
|
326
|
+
header: "#",
|
|
327
|
+
width: 4,
|
|
328
|
+
align: "right",
|
|
329
|
+
accessor: (row) => row.index,
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
header: "Title",
|
|
333
|
+
width: 40,
|
|
334
|
+
align: "left",
|
|
335
|
+
accessor: (row) => row.title,
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
header: "Session ID",
|
|
339
|
+
width: 24,
|
|
340
|
+
align: "left",
|
|
341
|
+
accessor: (row) => row.sessionId,
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
header: "Project ID",
|
|
345
|
+
width: 24,
|
|
346
|
+
align: "left",
|
|
347
|
+
accessor: (row) => row.projectId,
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
header: "Updated",
|
|
351
|
+
width: 16,
|
|
352
|
+
align: "left",
|
|
353
|
+
accessor: (row) => row.updatedAt,
|
|
354
|
+
format: (val) => formatDateForTable(val as Date | null | undefined),
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
header: "Created",
|
|
358
|
+
width: 16,
|
|
359
|
+
align: "left",
|
|
360
|
+
accessor: (row) => row.createdAt,
|
|
361
|
+
format: (val) => formatDateForTable(val as Date | null | undefined),
|
|
362
|
+
},
|
|
363
|
+
]
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Compact column definitions for sessions list (narrower terminals).
|
|
367
|
+
*/
|
|
368
|
+
export const sessionListColumnsCompact: ColumnDefinition<SessionRecord>[] = [
|
|
369
|
+
{
|
|
370
|
+
header: "#",
|
|
371
|
+
width: 4,
|
|
372
|
+
align: "right",
|
|
373
|
+
accessor: (row) => row.index,
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
header: "Title",
|
|
377
|
+
width: 30,
|
|
378
|
+
align: "left",
|
|
379
|
+
accessor: (row) => row.title,
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
header: "Session ID",
|
|
383
|
+
width: 20,
|
|
384
|
+
align: "left",
|
|
385
|
+
accessor: (row) => row.sessionId,
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
header: "Updated",
|
|
389
|
+
width: 16,
|
|
390
|
+
align: "left",
|
|
391
|
+
accessor: (row) => row.updatedAt,
|
|
392
|
+
format: (val) => formatDateForTable(val as Date | null | undefined),
|
|
393
|
+
},
|
|
394
|
+
]
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Format a sessions list as a table.
|
|
398
|
+
*/
|
|
399
|
+
export function formatSessionsTable(
|
|
400
|
+
sessions: SessionRecord[],
|
|
401
|
+
options?: TableFormatOptions & { compact?: boolean }
|
|
402
|
+
): string {
|
|
403
|
+
const columns = options?.compact ? sessionListColumnsCompact : sessionListColumns
|
|
404
|
+
return formatTable(sessions, columns, options)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Print a sessions list table to stdout.
|
|
409
|
+
*/
|
|
410
|
+
export function printSessionsTable(
|
|
411
|
+
sessions: SessionRecord[],
|
|
412
|
+
options?: TableFormatOptions & { compact?: boolean }
|
|
413
|
+
): void {
|
|
414
|
+
console.log(formatSessionsTable(sessions, options))
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ========================
|
|
418
|
+
// Chat List Columns
|
|
419
|
+
// ========================
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Format chat role with visual indicator.
|
|
423
|
+
*/
|
|
424
|
+
export function formatChatRole(role: ChatRole): string {
|
|
425
|
+
switch (role) {
|
|
426
|
+
case "user":
|
|
427
|
+
return "U"
|
|
428
|
+
case "assistant":
|
|
429
|
+
return "A"
|
|
430
|
+
case "unknown":
|
|
431
|
+
return "?"
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Format token count for display.
|
|
437
|
+
* Shows abbreviated number with K suffix for thousands.
|
|
438
|
+
*/
|
|
439
|
+
export function formatTokenCount(count: number | null | undefined): string {
|
|
440
|
+
if (count == null || count === 0) {
|
|
441
|
+
return "-"
|
|
442
|
+
}
|
|
443
|
+
if (count >= 1000) {
|
|
444
|
+
return `${(count / 1000).toFixed(1)}K`
|
|
445
|
+
}
|
|
446
|
+
return String(count)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Column definitions for chat list output.
|
|
451
|
+
*
|
|
452
|
+
* Columns: #, Role, MessageID, Preview, Tokens, Created
|
|
453
|
+
*/
|
|
454
|
+
export const chatListColumns: ColumnDefinition<ChatMessage & { index: number }>[] = [
|
|
455
|
+
{
|
|
456
|
+
header: "#",
|
|
457
|
+
width: 4,
|
|
458
|
+
align: "right",
|
|
459
|
+
accessor: (row) => row.index,
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
header: "Role",
|
|
463
|
+
width: 4,
|
|
464
|
+
align: "center",
|
|
465
|
+
accessor: (row) => row.role,
|
|
466
|
+
format: (role) => formatChatRole(role as ChatRole),
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
header: "Message ID",
|
|
470
|
+
width: 24,
|
|
471
|
+
align: "left",
|
|
472
|
+
accessor: (row) => row.messageId,
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
header: "Preview",
|
|
476
|
+
width: 40,
|
|
477
|
+
align: "left",
|
|
478
|
+
accessor: (row) => row.previewText,
|
|
479
|
+
},
|
|
480
|
+
{
|
|
481
|
+
header: "Tokens",
|
|
482
|
+
width: 8,
|
|
483
|
+
align: "right",
|
|
484
|
+
accessor: (row) => row.tokens?.total,
|
|
485
|
+
format: (val) => formatTokenCount(val as number | null | undefined),
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
header: "Created",
|
|
489
|
+
width: 16,
|
|
490
|
+
align: "left",
|
|
491
|
+
accessor: (row) => row.createdAt,
|
|
492
|
+
format: (val) => formatDateForTable(val as Date | null | undefined),
|
|
493
|
+
},
|
|
494
|
+
]
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Compact column definitions for chat list (narrower terminals).
|
|
498
|
+
*/
|
|
499
|
+
export const chatListColumnsCompact: ColumnDefinition<ChatMessage & { index: number }>[] = [
|
|
500
|
+
{
|
|
501
|
+
header: "#",
|
|
502
|
+
width: 4,
|
|
503
|
+
align: "right",
|
|
504
|
+
accessor: (row) => row.index,
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
header: "R",
|
|
508
|
+
width: 1,
|
|
509
|
+
align: "center",
|
|
510
|
+
accessor: (row) => row.role,
|
|
511
|
+
format: (role) => formatChatRole(role as ChatRole),
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
header: "Preview",
|
|
515
|
+
width: 50,
|
|
516
|
+
align: "left",
|
|
517
|
+
accessor: (row) => row.previewText,
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
header: "Tokens",
|
|
521
|
+
width: 8,
|
|
522
|
+
align: "right",
|
|
523
|
+
accessor: (row) => row.tokens?.total,
|
|
524
|
+
format: (val) => formatTokenCount(val as number | null | undefined),
|
|
525
|
+
},
|
|
526
|
+
]
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Format a chat list as a table.
|
|
530
|
+
* Messages are expected to have an index property added.
|
|
531
|
+
*/
|
|
532
|
+
export function formatChatTable(
|
|
533
|
+
messages: (ChatMessage & { index: number })[],
|
|
534
|
+
options?: TableFormatOptions & { compact?: boolean }
|
|
535
|
+
): string {
|
|
536
|
+
const columns = options?.compact ? chatListColumnsCompact : chatListColumns
|
|
537
|
+
return formatTable(messages, columns, options)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Print a chat list table to stdout.
|
|
542
|
+
*/
|
|
543
|
+
export function printChatTable(
|
|
544
|
+
messages: (ChatMessage & { index: number })[],
|
|
545
|
+
options?: TableFormatOptions & { compact?: boolean }
|
|
546
|
+
): void {
|
|
547
|
+
console.log(formatChatTable(messages, options))
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ========================
|
|
551
|
+
// Tokens Summary Formatting
|
|
552
|
+
// ========================
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Row type for token breakdown table.
|
|
556
|
+
* Each row represents a single token category.
|
|
557
|
+
*/
|
|
558
|
+
export interface TokenBreakdownRow {
|
|
559
|
+
category: string
|
|
560
|
+
count: number
|
|
561
|
+
percentage: number
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Convert a TokenBreakdown into table rows.
|
|
566
|
+
*/
|
|
567
|
+
export function tokenBreakdownToRows(breakdown: TokenBreakdown): TokenBreakdownRow[] {
|
|
568
|
+
const total = breakdown.total || 1 // avoid division by zero
|
|
569
|
+
return [
|
|
570
|
+
{ category: "Input", count: breakdown.input, percentage: (breakdown.input / total) * 100 },
|
|
571
|
+
{ category: "Output", count: breakdown.output, percentage: (breakdown.output / total) * 100 },
|
|
572
|
+
{ category: "Reasoning", count: breakdown.reasoning, percentage: (breakdown.reasoning / total) * 100 },
|
|
573
|
+
{ category: "Cache Read", count: breakdown.cacheRead, percentage: (breakdown.cacheRead / total) * 100 },
|
|
574
|
+
{ category: "Cache Write", count: breakdown.cacheWrite, percentage: (breakdown.cacheWrite / total) * 100 },
|
|
575
|
+
{ category: "Total", count: breakdown.total, percentage: 100 },
|
|
576
|
+
]
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Format a percentage for display.
|
|
581
|
+
*/
|
|
582
|
+
export function formatPercentage(value: number): string {
|
|
583
|
+
if (value === 0) {
|
|
584
|
+
return "-"
|
|
585
|
+
}
|
|
586
|
+
if (value === 100) {
|
|
587
|
+
return "100%"
|
|
588
|
+
}
|
|
589
|
+
return `${value.toFixed(1)}%`
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Format a large number with K/M suffix.
|
|
594
|
+
*/
|
|
595
|
+
export function formatLargeNumber(value: number): string {
|
|
596
|
+
if (value === 0) {
|
|
597
|
+
return "0"
|
|
598
|
+
}
|
|
599
|
+
if (value >= 1_000_000) {
|
|
600
|
+
return `${(value / 1_000_000).toFixed(2)}M`
|
|
601
|
+
}
|
|
602
|
+
if (value >= 1000) {
|
|
603
|
+
return `${(value / 1000).toFixed(1)}K`
|
|
604
|
+
}
|
|
605
|
+
return String(value)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Column definitions for token breakdown table.
|
|
610
|
+
*/
|
|
611
|
+
export const tokenBreakdownColumns: ColumnDefinition<TokenBreakdownRow>[] = [
|
|
612
|
+
{
|
|
613
|
+
header: "Category",
|
|
614
|
+
width: 12,
|
|
615
|
+
align: "left",
|
|
616
|
+
accessor: (row) => row.category,
|
|
617
|
+
},
|
|
618
|
+
{
|
|
619
|
+
header: "Tokens",
|
|
620
|
+
width: 12,
|
|
621
|
+
align: "right",
|
|
622
|
+
accessor: (row) => row.count,
|
|
623
|
+
format: (val) => formatLargeNumber(val as number),
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
header: "%",
|
|
627
|
+
width: 8,
|
|
628
|
+
align: "right",
|
|
629
|
+
accessor: (row) => row.percentage,
|
|
630
|
+
format: (val) => formatPercentage(val as number),
|
|
631
|
+
},
|
|
632
|
+
]
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Format a TokenBreakdown as a table.
|
|
636
|
+
*/
|
|
637
|
+
export function formatTokenBreakdownTable(
|
|
638
|
+
breakdown: TokenBreakdown,
|
|
639
|
+
options?: TableFormatOptions
|
|
640
|
+
): string {
|
|
641
|
+
const rows = tokenBreakdownToRows(breakdown)
|
|
642
|
+
return formatTable(rows, tokenBreakdownColumns, options)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Print a TokenBreakdown table to stdout.
|
|
647
|
+
*/
|
|
648
|
+
export function printTokenBreakdownTable(
|
|
649
|
+
breakdown: TokenBreakdown,
|
|
650
|
+
options?: TableFormatOptions
|
|
651
|
+
): void {
|
|
652
|
+
console.log(formatTokenBreakdownTable(breakdown, options))
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Format a TokenSummary for display.
|
|
657
|
+
* Returns a table for known summaries, or a message for unknown.
|
|
658
|
+
*/
|
|
659
|
+
export function formatTokenSummary(
|
|
660
|
+
summary: TokenSummary,
|
|
661
|
+
options?: TableFormatOptions
|
|
662
|
+
): string {
|
|
663
|
+
if (summary.kind === "known") {
|
|
664
|
+
return formatTokenBreakdownTable(summary.tokens, options)
|
|
665
|
+
}
|
|
666
|
+
// Unknown summary - return reason message
|
|
667
|
+
switch (summary.reason) {
|
|
668
|
+
case "missing":
|
|
669
|
+
return "[Token data unavailable]"
|
|
670
|
+
case "parse_error":
|
|
671
|
+
return "[Token data parse error]"
|
|
672
|
+
case "no_messages":
|
|
673
|
+
return "[No messages found]"
|
|
674
|
+
default:
|
|
675
|
+
return "[Unknown token status]"
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Print a TokenSummary to stdout.
|
|
681
|
+
*/
|
|
682
|
+
export function printTokenSummary(
|
|
683
|
+
summary: TokenSummary,
|
|
684
|
+
options?: TableFormatOptions
|
|
685
|
+
): void {
|
|
686
|
+
console.log(formatTokenSummary(summary, options))
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Row type for aggregate token summary table.
|
|
691
|
+
*/
|
|
692
|
+
export interface AggregateTokenRow {
|
|
693
|
+
label: string
|
|
694
|
+
value: string
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Format an AggregateTokenSummary as a detailed summary.
|
|
699
|
+
* Includes breakdown table plus metadata about unknown sessions.
|
|
700
|
+
*/
|
|
701
|
+
export function formatAggregateTokenSummary(
|
|
702
|
+
summary: AggregateTokenSummary,
|
|
703
|
+
options?: TableFormatOptions & { label?: string }
|
|
704
|
+
): string {
|
|
705
|
+
const lines: string[] = []
|
|
706
|
+
const label = options?.label ?? "Token Summary"
|
|
707
|
+
|
|
708
|
+
lines.push(label)
|
|
709
|
+
lines.push("=".repeat(label.length))
|
|
710
|
+
lines.push("")
|
|
711
|
+
|
|
712
|
+
if (summary.total.kind === "known") {
|
|
713
|
+
lines.push(formatTokenBreakdownTable(summary.total.tokens, options))
|
|
714
|
+
} else {
|
|
715
|
+
lines.push(formatTokenSummary(summary.total, options))
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Add metadata if there are unknown sessions
|
|
719
|
+
if (summary.unknownSessions && summary.unknownSessions > 0) {
|
|
720
|
+
lines.push("")
|
|
721
|
+
lines.push(`Note: ${summary.unknownSessions} session(s) with unavailable token data`)
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return lines.join("\n")
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Print an AggregateTokenSummary to stdout.
|
|
729
|
+
*/
|
|
730
|
+
export function printAggregateTokenSummary(
|
|
731
|
+
summary: AggregateTokenSummary,
|
|
732
|
+
options?: TableFormatOptions & { label?: string }
|
|
733
|
+
): void {
|
|
734
|
+
console.log(formatAggregateTokenSummary(summary, options))
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ========================
|
|
738
|
+
// Chat Search Results Columns
|
|
739
|
+
// ========================
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Indexed chat search result for display.
|
|
743
|
+
*/
|
|
744
|
+
export type IndexedChatSearchResult = ChatSearchResult & { index: number }
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Column definitions for chat search results output.
|
|
748
|
+
*
|
|
749
|
+
* Columns: #, Role, Session, Match, Created
|
|
750
|
+
*/
|
|
751
|
+
export const chatSearchColumns: ColumnDefinition<IndexedChatSearchResult>[] = [
|
|
752
|
+
{
|
|
753
|
+
header: "#",
|
|
754
|
+
width: 4,
|
|
755
|
+
align: "right",
|
|
756
|
+
accessor: (row) => row.index,
|
|
757
|
+
},
|
|
758
|
+
{
|
|
759
|
+
header: "Role",
|
|
760
|
+
width: 4,
|
|
761
|
+
align: "center",
|
|
762
|
+
accessor: (row) => row.role,
|
|
763
|
+
format: (role) => formatChatRole(role as ChatRole),
|
|
764
|
+
},
|
|
765
|
+
{
|
|
766
|
+
header: "Session",
|
|
767
|
+
width: 30,
|
|
768
|
+
align: "left",
|
|
769
|
+
accessor: (row) => row.sessionTitle,
|
|
770
|
+
},
|
|
771
|
+
{
|
|
772
|
+
header: "Match",
|
|
773
|
+
width: 50,
|
|
774
|
+
align: "left",
|
|
775
|
+
accessor: (row) => row.matchedText,
|
|
776
|
+
},
|
|
777
|
+
{
|
|
778
|
+
header: "Created",
|
|
779
|
+
width: 16,
|
|
780
|
+
align: "left",
|
|
781
|
+
accessor: (row) => row.createdAt,
|
|
782
|
+
format: (val) => formatDateForTable(val as Date | null | undefined),
|
|
783
|
+
},
|
|
784
|
+
]
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Compact column definitions for chat search results (narrower terminals).
|
|
788
|
+
*/
|
|
789
|
+
export const chatSearchColumnsCompact: ColumnDefinition<IndexedChatSearchResult>[] = [
|
|
790
|
+
{
|
|
791
|
+
header: "#",
|
|
792
|
+
width: 4,
|
|
793
|
+
align: "right",
|
|
794
|
+
accessor: (row) => row.index,
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
header: "R",
|
|
798
|
+
width: 1,
|
|
799
|
+
align: "center",
|
|
800
|
+
accessor: (row) => row.role,
|
|
801
|
+
format: (role) => formatChatRole(role as ChatRole),
|
|
802
|
+
},
|
|
803
|
+
{
|
|
804
|
+
header: "Session",
|
|
805
|
+
width: 25,
|
|
806
|
+
align: "left",
|
|
807
|
+
accessor: (row) => row.sessionTitle,
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
header: "Match",
|
|
811
|
+
width: 60,
|
|
812
|
+
align: "left",
|
|
813
|
+
accessor: (row) => row.matchedText,
|
|
814
|
+
},
|
|
815
|
+
]
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Format chat search results as a table.
|
|
819
|
+
* Results are expected to have an index property added.
|
|
820
|
+
*/
|
|
821
|
+
export function formatChatSearchTable(
|
|
822
|
+
results: IndexedChatSearchResult[],
|
|
823
|
+
options?: TableFormatOptions & { compact?: boolean }
|
|
824
|
+
): string {
|
|
825
|
+
const columns = options?.compact ? chatSearchColumnsCompact : chatSearchColumns
|
|
826
|
+
return formatTable(results, columns, options)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Print chat search results table to stdout.
|
|
831
|
+
*/
|
|
832
|
+
export function printChatSearchTable(
|
|
833
|
+
results: IndexedChatSearchResult[],
|
|
834
|
+
options?: TableFormatOptions & { compact?: boolean }
|
|
835
|
+
): void {
|
|
836
|
+
console.log(formatChatSearchTable(results, options))
|
|
837
|
+
}
|