Skip to content
This repository was archived by the owner on Feb 25, 2026. It is now read-only.

Commit 26183e0

Browse files
committed
feat(roll-call): add table formatting with adaptive widths and TTY-aware colors
1 parent 37f2504 commit 26183e0

2 files changed

Lines changed: 286 additions & 34 deletions

File tree

packages/opencode/src/cli/cmd/roll-call.ts

Lines changed: 77 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,61 @@ import { ProviderError } from "../../provider/error"
99
import { generateText } from "ai"
1010
import { randomUUID } from "crypto"
1111

12+
const HEADERS = ["Model", "Access", "Snippet", "Latency"]
13+
const SEPARATOR_PADDING = 9 // " | " between 4 columns = 3 * 3 = 9 chars
14+
15+
// Detect if stderr is a TTY for conditional color output
16+
const isTTY = process.stderr.isTTY ?? false
17+
18+
// Helper to conditionally apply colors only when output is to a TTY
19+
function color(style: string): string {
20+
return isTTY ? style : ""
21+
}
22+
23+
// Strip ANSI escape sequences and control characters for accurate width calculation
24+
function sanitize(text: string): string {
25+
return text
26+
.replace(/\x1b\[[0-9;]*m/g, "") // ANSI color codes
27+
.replace(/[\x00-\x1f\x7f]/g, "") // control characters (including \0, \n, etc.)
28+
}
29+
30+
function truncate(text: string, maxLen: number): string {
31+
if (maxLen < 4) return text.substring(0, maxLen)
32+
return text.length > maxLen ? text.substring(0, maxLen - 3) + "..." : text
33+
}
34+
35+
export function formatTable(
36+
rows: string[][],
37+
terminalWidth: number,
38+
): { header: string; separator: string; rows: string[] } {
39+
// Sanitize all cell content to strip control chars and ANSI codes
40+
const sanitizedRows = rows.map((row) => row.map((cell) => sanitize(cell ?? "")))
41+
42+
// Calculate natural width for each column based on sanitized content
43+
const widths = HEADERS.map((h, i) => Math.max(h.length, ...sanitizedRows.map((r) => r[i].length)))
44+
45+
// Total width with separators
46+
const totalWidth = widths.reduce((a, b) => a + b, 0) + SEPARATOR_PADDING
47+
48+
// Only shrink snippet column (index 2) if total exceeds terminal width
49+
// Minimum snippet width is header length (7) + 3 chars for meaningful content with "..."
50+
const minSnippetWidth = HEADERS[2].length + 3
51+
if (totalWidth > terminalWidth && widths[2] > minSnippetWidth) {
52+
const overflow = totalWidth - terminalWidth
53+
widths[2] = Math.max(minSnippetWidth, widths[2] - overflow)
54+
}
55+
56+
const header = HEADERS.map((h, i) => h.padEnd(widths[i])).join(" | ")
57+
const separator = "-".repeat(header.length)
58+
59+
const formattedRows = sanitizedRows.map((row) => {
60+
const truncatedRow = [row[0], row[1], row[2] ? truncate(row[2], widths[2]) : row[2], row[3]]
61+
return truncatedRow.map((c, i) => c.padEnd(widths[i])).join(" | ")
62+
})
63+
64+
return { header, separator, rows: formattedRows }
65+
}
66+
1267
export const RollCallCommand = cmd({
1368
command: "roll-call <filter>",
1469
describe: "batch-test models matching a filter for connectivity and latency",
@@ -74,9 +129,11 @@ export async function rollCallHandler(args: any) {
74129
const { prompt, timeout, filter, parallel, output, verbose, quiet } = args
75130

76131
if (!quiet) {
77-
UI.println(`${UI.Style.TEXT_INFO}Starting roll call for models with prompt: "${prompt}"${UI.Style.TEXT_NORMAL}`)
78132
UI.println(
79-
`${UI.Style.TEXT_INFO}Timeout per model: ${timeout}ms, Parallel calls: ${parallel}${UI.Style.TEXT_NORMAL}`,
133+
`${color(UI.Style.TEXT_INFO)}Starting roll call for models with prompt: "${prompt}"${color(UI.Style.TEXT_NORMAL)}`,
134+
)
135+
UI.println(
136+
`${color(UI.Style.TEXT_INFO)}Timeout per model: ${timeout}ms, Parallel calls: ${parallel}${color(UI.Style.TEXT_NORMAL)}`,
80137
)
81138
}
82139

@@ -103,12 +160,15 @@ export async function rollCallHandler(args: any) {
103160
}
104161

105162
if (modelsToTest.length === 0) {
106-
if (!quiet) UI.println(`${UI.Style.TEXT_WARNING}No models to test after filtering.${UI.Style.TEXT_NORMAL}`)
163+
if (!quiet)
164+
UI.println(`${color(UI.Style.TEXT_WARNING)}No models to test after filtering.${color(UI.Style.TEXT_NORMAL)}`)
107165
return
108166
}
109167

110168
if (!quiet) {
111-
UI.println(`${UI.Style.TEXT_INFO}Prompting ${modelsToTest.length} models...${UI.Style.TEXT_NORMAL}`)
169+
UI.println(
170+
`${color(UI.Style.TEXT_INFO)}Prompting ${modelsToTest.length} models...${color(UI.Style.TEXT_NORMAL)}`,
171+
)
112172
}
113173

114174
const results: RollCallResult[] = []
@@ -148,8 +208,7 @@ export async function rollCallHandler(args: any) {
148208
providerOptions,
149209
})
150210
access = true
151-
const cleaned = text.replace(/\n/g, " ")
152-
snippet = cleaned.length > 50 ? cleaned.substring(0, 47) + "..." : cleaned
211+
snippet = text.replace(/\n/g, " ")
153212
latency = Date.now() - startTime
154213
} catch (e: any) {
155214
latency = Date.now() - startTime
@@ -177,9 +236,11 @@ export async function rollCallHandler(args: any) {
177236

178237
if (verbose && !quiet) {
179238
if (access) {
180-
UI.println(`${UI.Style.TEXT_SUCCESS}${UI.Style.TEXT_NORMAL} ${fullName} - ${latency}ms`)
239+
UI.println(`${color(UI.Style.TEXT_SUCCESS)}${color(UI.Style.TEXT_NORMAL)} ${fullName} - ${latency}ms`)
181240
} else {
182-
UI.println(`${UI.Style.TEXT_DANGER}${UI.Style.TEXT_NORMAL} ${fullName} - ${errorType}: ${errorMessage}`)
241+
UI.println(
242+
`${color(UI.Style.TEXT_DANGER)}${color(UI.Style.TEXT_NORMAL)} ${fullName} - ${errorType}: ${errorMessage}`,
243+
)
183244
}
184245
}
185246
}
@@ -205,46 +266,28 @@ export async function rollCallHandler(args: any) {
205266
if (output === "json") {
206267
console.log(JSON.stringify(results, null, 2))
207268
} else {
208-
const headers = ["Model", "Access", "Snippet", "Latency"]
209-
210-
const truncate = (text: string, maxLen: number) => {
211-
if (maxLen < 10) return text.substring(0, maxLen - 3) + "..."
212-
return text.length > maxLen ? text.substring(0, maxLen - 3) + "..." : text
213-
}
214-
215269
const rows = results.map((r) => [
216270
r.model,
217271
r.access ? "YES" : "NO",
218272
r.access ? r.snippet : r.errorMessage ? `(${r.errorMessage})` : "",
219273
r.latency !== null ? `${r.latency}ms` : "N/A",
220274
])
221275

222-
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)))
223-
224-
const totalWidth = widths.reduce((a, b) => a + b, 0) + 9
225-
const terminalWidth = process.stdout.columns || 120
226-
227-
if (totalWidth > terminalWidth && widths[2] > 20) {
228-
widths[2] = Math.max(20, widths[2] - (totalWidth - terminalWidth))
229-
}
230-
231-
const headerRow = headers.map((h, i) => h.padEnd(widths[i])).join(" | ")
232-
UI.println(headerRow)
233-
UI.println("-".repeat(headerRow.length))
276+
const terminalWidth = parseInt(process.env.COLUMNS || "", 10) || process.stdout.columns || 80
277+
const table = formatTable(rows, terminalWidth)
234278

235-
rows.forEach((row, idx) => {
236-
const result = results[idx]
237-
const color = result.access ? UI.Style.TEXT_SUCCESS : UI.Style.TEXT_DANGER
238-
const truncatedRow = [row[0], row[1], row[2] ? truncate(row[2], widths[2]) : row[2], row[3]]
239-
const line = truncatedRow.map((c, i) => c.padEnd(widths[i])).join(" | ")
240-
UI.println(color + line + UI.Style.TEXT_NORMAL)
279+
UI.println(table.header)
280+
UI.println(table.separator)
281+
table.rows.forEach((line, idx) => {
282+
const rowColor = results[idx].access ? UI.Style.TEXT_SUCCESS : UI.Style.TEXT_DANGER
283+
UI.println(color(rowColor) + line + color(UI.Style.TEXT_NORMAL))
241284
})
242285

243286
const successful = results.filter((r) => r.access).length
244287
const failed = results.length - successful
245288
UI.println("")
246289
UI.println(
247-
`${UI.Style.TEXT_SUCCESS}${successful} accessible${UI.Style.TEXT_NORMAL}, ${UI.Style.TEXT_DANGER}${failed} failed${UI.Style.TEXT_NORMAL}`,
290+
`${color(UI.Style.TEXT_SUCCESS)}${successful} accessible${color(UI.Style.TEXT_NORMAL)}, ${color(UI.Style.TEXT_DANGER)}${failed} failed${color(UI.Style.TEXT_NORMAL)}`,
248291
)
249292
}
250293
},
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// kilocode_change - new file
2+
import { describe, expect, test } from "bun:test"
3+
import { formatTable } from "../../src/cli/cmd/roll-call"
4+
5+
// NOTE: We test formatTable function directly. The color() helper and TTY detection
6+
// are tested indirectly through the actual roll-call command execution, as they
7+
// depend on process.stderr.isTTY which changes based on how the process is invoked.
8+
9+
describe("formatTable", () => {
10+
describe("column width calculation", () => {
11+
test("columns grow to fit content", () => {
12+
const rows = [["kilo/provider/model-name", "YES", "Hi", "100ms"]]
13+
const result = formatTable(rows, 120)
14+
15+
// Model column should be wide enough for the model name
16+
expect(result.header).toContain("Model")
17+
expect(result.rows[0]).toContain("kilo/provider/model-name")
18+
// All columns should be present
19+
expect(result.rows[0]).toContain("YES")
20+
expect(result.rows[0]).toContain("Hi")
21+
expect(result.rows[0]).toContain("100ms")
22+
})
23+
24+
test("short snippet does not force minimum width", () => {
25+
const rows = [["m", "YES", "Hi", "1ms"]]
26+
const result = formatTable(rows, 120)
27+
28+
// Table should be compact when content is short
29+
// Header is: "Model | Access | Snippet | Latency"
30+
// Minimum widths are header lengths: Model=5, Access=6, Snippet=7, Latency=7
31+
// With separators: 5 + 6 + 7 + 7 + 9 = 34
32+
expect(result.header.length).toBeLessThan(50)
33+
expect(result.separator.length).toBe(result.header.length)
34+
})
35+
36+
test("separator length matches header length", () => {
37+
const rows = [
38+
["kilo/openai/gpt-4", "YES", "Hello there!", "500ms"],
39+
["kilo/anthropic/claude", "NO", "(Error)", "100ms"],
40+
]
41+
const result = formatTable(rows, 120)
42+
43+
expect(result.separator.length).toBe(result.header.length)
44+
expect(result.separator).toMatch(/^-+$/)
45+
})
46+
47+
test("all rows have same length as header", () => {
48+
const rows = [
49+
["short", "YES", "Hello", "10ms"],
50+
["kilo/very/long/provider/model-name", "NO", "(Some error message)", "1000ms"],
51+
]
52+
const result = formatTable(rows, 120)
53+
54+
for (const row of result.rows) {
55+
expect(row.length).toBe(result.header.length)
56+
}
57+
})
58+
})
59+
60+
describe("snippet truncation", () => {
61+
test("long snippet is truncated with ellipsis when exceeding terminal width", () => {
62+
const longSnippet = "This is a very long snippet that should be truncated because it exceeds the available width"
63+
const rows = [["kilo/provider/model", "YES", longSnippet, "100ms"]]
64+
const result = formatTable(rows, 80) // narrow terminal
65+
66+
expect(result.rows[0]).toContain("...")
67+
expect(result.rows[0].length).toBeLessThanOrEqual(80)
68+
})
69+
70+
test("snippet is not truncated when table fits terminal width", () => {
71+
const snippet = "Short response"
72+
const rows = [["m", "YES", snippet, "1ms"]]
73+
const result = formatTable(rows, 120)
74+
75+
expect(result.rows[0]).toContain(snippet)
76+
expect(result.rows[0]).not.toContain("...")
77+
})
78+
79+
test("table fits within terminal width when content exceeds available space", () => {
80+
// This test reproduces the "off by 2" bug where table was 2 chars too wide
81+
const rows = [
82+
["kilo/openrouter/free", "YES", "Le temps passe vi...", "802ms"],
83+
["kilo/arcee-ai/trinity-large-preview:free", "YES", '"Le soleil brille...', "1527ms"],
84+
["kilo/minimax/minimax-m2.5:free", "YES", "Voici une phrase ...", "2615ms"],
85+
["kilo/stepfun/step-3.5-flash:free", "YES", "Aujourd'hui, je s...", "3561ms"],
86+
["kilo/corethink:free", "NO", "(Invalid JSON res...", "18490ms"],
87+
["kilo/z-ai/glm-5:free", "NO", "(The operation ti...", "25010ms"],
88+
]
89+
const terminalWidth = 80
90+
const result = formatTable(rows, terminalWidth)
91+
92+
// Header, separator, and all rows must fit within terminal width
93+
expect(result.header.length).toBeLessThanOrEqual(terminalWidth)
94+
expect(result.separator.length).toBeLessThanOrEqual(terminalWidth)
95+
for (const row of result.rows) {
96+
expect(row.length).toBeLessThanOrEqual(terminalWidth)
97+
}
98+
})
99+
100+
test("truncate handles very short maxLen gracefully", () => {
101+
const rows = [["m", "YES", "Hello World", "1ms"]]
102+
// This shouldn't crash even with extreme truncation
103+
const result = formatTable(rows, 30)
104+
expect(result.rows[0]).toBeDefined()
105+
})
106+
})
107+
108+
describe("error messages", () => {
109+
test("error message in parentheses is displayed", () => {
110+
const rows = [["kilo/provider/model", "NO", "(Connection refused)", "500ms"]]
111+
const result = formatTable(rows, 120)
112+
113+
expect(result.rows[0]).toContain("(Connection refused)")
114+
})
115+
116+
test("empty snippet cell is handled", () => {
117+
const rows = [["kilo/provider/model", "NO", "", "500ms"]]
118+
const result = formatTable(rows, 120)
119+
120+
// Should not crash and row should be formatted
121+
expect(result.rows[0]).toContain("kilo/provider/model")
122+
expect(result.rows[0]).toContain("NO")
123+
expect(result.rows[0]).toContain("500ms")
124+
})
125+
})
126+
127+
describe("sanitization", () => {
128+
test("strips ANSI color codes from snippet", () => {
129+
const rows = [["model", "YES", "\x1b[92mGreen text\x1b[0m", "100ms"]]
130+
const result = formatTable(rows, 120)
131+
132+
expect(result.rows[0]).toContain("Green text")
133+
expect(result.rows[0]).not.toContain("\x1b")
134+
})
135+
136+
test("strips null bytes and control characters", () => {
137+
const rows = [["model", "YES", "Hello\x00World\x01Test", "100ms"]]
138+
const result = formatTable(rows, 120)
139+
140+
expect(result.rows[0]).toContain("HelloWorldTest")
141+
expect(result.rows[0]).not.toContain("\x00")
142+
expect(result.rows[0]).not.toContain("\x01")
143+
})
144+
145+
test("width calculation uses sanitized content", () => {
146+
// ANSI codes add bytes but not visible width
147+
const withAnsi = "\x1b[92mHi\x1b[0m" // "Hi" with color codes = 11 bytes but 2 visible chars
148+
const rows = [["m", "YES", withAnsi, "1ms"]]
149+
const result = formatTable(rows, 120)
150+
151+
// Snippet column should be sized for "Hi" (2 chars), not 11 bytes
152+
// Header "Snippet" is 7 chars, so minimum width is 7
153+
const snippetColStart = result.header.indexOf("Snippet")
154+
const accessColEnd = result.header.indexOf("Access") + "Access".length
155+
const snippetWidth = result.header.indexOf(" | Latency") - snippetColStart
156+
157+
expect(snippetWidth).toBe(7) // "Snippet" header length, not inflated by ANSI codes
158+
})
159+
160+
test("strips newlines from content", () => {
161+
const rows = [["model", "YES", "Line1\nLine2", "100ms"]]
162+
const result = formatTable(rows, 120)
163+
164+
expect(result.rows[0]).toContain("Line1Line2")
165+
expect(result.rows[0]).not.toContain("\n")
166+
})
167+
})
168+
169+
describe("edge cases", () => {
170+
test("empty rows array", () => {
171+
const result = formatTable([], 120)
172+
173+
expect(result.header).toContain("Model")
174+
expect(result.rows).toHaveLength(0)
175+
})
176+
177+
test("handles undefined cells gracefully", () => {
178+
const rows = [["model", "YES", undefined as unknown as string, "100ms"]]
179+
const result = formatTable(rows, 120)
180+
181+
// Should not crash
182+
expect(result.rows[0]).toContain("model")
183+
})
184+
185+
test("very narrow terminal still produces valid output", () => {
186+
const rows = [["kilo/provider/model", "YES", "Hello", "100ms"]]
187+
const result = formatTable(rows, 40)
188+
189+
// Should produce valid output even if truncated
190+
expect(result.header.length).toBeGreaterThan(0)
191+
expect(result.rows[0].length).toBe(result.header.length)
192+
})
193+
194+
test("terminal width of 120 (default) handles typical content", () => {
195+
const rows = [
196+
["kilo/openai/gpt-4", "YES", "Hello! How can I help you today?", "500ms"],
197+
["kilo/anthropic/claude-3-opus", "YES", "Hi there! I'm Claude, an AI assistant...", "1200ms"],
198+
["kilo/google/gemini-pro", "NO", "(Rate limit exceeded)", "100ms"],
199+
]
200+
const result = formatTable(rows, 120)
201+
202+
// All rows should fit within 120 chars
203+
expect(result.header.length).toBeLessThanOrEqual(120)
204+
for (const row of result.rows) {
205+
expect(row.length).toBeLessThanOrEqual(120)
206+
}
207+
})
208+
})
209+
})

0 commit comments

Comments
 (0)