Skip to content

Commit 0978d6b

Browse files
Merge provider-setup short-terminal garbling fix
2 parents 0f57090 + 0d021d4 commit 0978d6b

3 files changed

Lines changed: 167 additions & 0 deletions

File tree

‎src/tui-opentui/palette-paint.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import type { KeyEvent } from "@opentui/core"
88

99
import { withTestRenderer } from "./harness"
1010
import { openModelPickerOverlay } from "./overlays"
11+
import type { PaletteCommand } from "./palette"
1112
import {
1213
createAppShell,
1314
handlePaletteFilterKey,
15+
moveOverlaySelection,
1416
openPalette,
1517
type AppShell,
1618
} from "./shell"
@@ -256,3 +258,63 @@ describe("command palette selection colour", () => {
256258
})
257259
})
258260

261+
describe("command palette height cap", () => {
262+
const BIG_CATALOG: readonly PaletteCommand[] = Array.from(
263+
{ length: 50 },
264+
(_, i) => ({
265+
id: `cmd_${String(i)}`,
266+
label: `Fake command number ${String(i)} with a longish label`,
267+
dispatch: "command" as const,
268+
}),
269+
)
270+
271+
// Every plugin-inflated catalog and every terminal size gets a bounded
272+
// frame: the border-to-border row count above the prompt box never grows
273+
// past the terminal, and the box below stays intact and readable.
274+
for (const height of [24, 16, 12, 8, 6]) {
275+
test(`stays within a ${height}-row terminal and keeps the prompt box intact`, async () => {
276+
await withTestRenderer(
277+
async (h) => {
278+
const shell = createAppShell(h.renderer, {
279+
terminal: { columns: 80, rows: height },
280+
wireKeys: false,
281+
run: "idle",
282+
})
283+
openPalette(shell, { catalog: BIG_CATALOG, title: "commands · /" })
284+
await h.renderOnce()
285+
const lines = h.captureCharFrame().split("\n")
286+
// captureCharFrame's trailing newline yields one extra split
287+
// element — the frame itself must not exceed the terminal rows.
288+
expect(lines.length).toBeLessThanOrEqual(height + 1)
289+
expect(lines.some((l) => l.includes("message…"))).toBe(true)
290+
},
291+
{ width: 80, height },
292+
)
293+
})
294+
}
295+
296+
test("scrolling the selection keeps the active row inside the window", async () => {
297+
await withTestRenderer(
298+
async (h) => {
299+
const shell = createAppShell(h.renderer, {
300+
terminal: { columns: 80, rows: 12 },
301+
wireKeys: false,
302+
run: "idle",
303+
})
304+
openPalette(shell, { catalog: BIG_CATALOG, title: "commands · /" })
305+
await h.renderOnce()
306+
for (let i = 0; i < 20; i++) moveOverlaySelection(shell, 1)
307+
await h.renderOnce()
308+
expect(shell.overlayList?.activeIndex).toBe(20)
309+
const offset = shell.overlayList?.offset ?? 0
310+
const height = shell.overlayList?.height ?? 0
311+
expect(offset).toBeLessThanOrEqual(20)
312+
expect(offset + height).toBeGreaterThan(20)
313+
const frame = h.captureCharFrame()
314+
expect(frame).toContain(`Fake command number 20`)
315+
},
316+
{ width: 80, height: 12 },
317+
)
318+
})
319+
})
320+

‎src/tui-opentui/provider-setup.test.ts‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -693,3 +693,86 @@ describe("runProviderSetup paste", () => {
693693
expect(values?.apiKey).toBe(key)
694694
})
695695
})
696+
697+
describe("runProviderSetup pick-list height cap", () => {
698+
// Every terminal size gets a bounded frame — no chrome row overlaps
699+
// another (the header/intro/step/instruction rows used to compress into
700+
// each other when the flex column ran out of room), and the picker never
701+
// paints past the terminal's own row count.
702+
for (const height of [24, 16, 12, 8, 6]) {
703+
test(`stays within a ${height}-row terminal with no overlapping chrome`, async () => {
704+
const harness = await createHarness({ width: 80, height })
705+
runProviderSetup({
706+
onSubmit: async () => {},
707+
showTelemetryNotice: false,
708+
createRenderer: async () => harness.renderer,
709+
})
710+
await harness.renderOnce()
711+
await harness.renderOnce()
712+
const lines = harness.captureCharFrame().split("\n")
713+
expect(lines.length).toBeLessThanOrEqual(height + 1)
714+
// The garbled-overlap bug glued the step line and the intro line
715+
// together on one row; each survives as its own line, or is clipped
716+
// entirely, but never merges into the other.
717+
const stepLine = lines.find((l) => l.includes("step 1 of 3"))
718+
if (stepLine !== undefined) {
719+
expect(stepLine).not.toContain("connect an inference provider")
720+
}
721+
})
722+
}
723+
724+
test("keyboard navigation scrolls a long provider list and keeps the active row visible", async () => {
725+
const harness = await createHarness({ width: 80, height: 16 })
726+
runProviderSetup({
727+
onSubmit: async () => {},
728+
showTelemetryNotice: false,
729+
createRenderer: async () => harness.renderer,
730+
})
731+
await harness.renderOnce()
732+
await harness.renderOnce()
733+
const ids = providerChoiceRows(providerChoices()).map((r) => r.id)
734+
for (let i = 0; i < ids.length - 1; i++) harness.pressKey("ARROW_DOWN")
735+
await harness.renderOnce()
736+
const frame = harness.captureCharFrame()
737+
const last = providerChoiceRows(providerChoices()).at(-1)
738+
expect(last).toBeDefined()
739+
expect(frame).toContain(last!.label.slice(0, 20))
740+
})
741+
742+
// statusLine and guidance are both blank on the first screen these tests
743+
// exercised — the garbling only showed up once a failed connection test
744+
// populates both of them at once, so walk the flow there instead of
745+
// stopping at the provider pick-list.
746+
test("a failed connection test at a short terminal shows status and guidance on their own lines", async () => {
747+
const harness = await createHarness({ width: 80, height: 16 })
748+
runProviderSetup({
749+
onSubmit: async (_values, _setPhase, opts) => {
750+
if (!opts.skipValidation) throw new Error("connection refused")
751+
},
752+
showTelemetryNotice: false,
753+
createRenderer: async () => harness.renderer,
754+
})
755+
await harness.renderOnce()
756+
await harness.renderOnce()
757+
await pickRow(harness, PROVIDER_IDS, "openai")
758+
type(harness, "sk-key")
759+
harness.pressKey("Enter")
760+
await harness.renderOnce()
761+
harness.pressKey("Enter")
762+
await harness.renderOnce()
763+
await new Promise((r) => setTimeout(r, 0))
764+
await harness.renderOnce()
765+
766+
const lines = harness.captureCharFrame().split("\n")
767+
expect(lines.length).toBeLessThanOrEqual(17)
768+
const statusRow = lines.find((l) => l.includes("connection refused"))
769+
const guidanceRow = lines.find((l) => l.includes("esc to re-enter"))
770+
expect(statusRow).toBeDefined()
771+
expect(guidanceRow).toBeDefined()
772+
// The garbling bug glued these two rows together; each must survive as
773+
// its own line, never merged into the other.
774+
expect(statusRow).not.toBe(guidanceRow)
775+
expect(statusRow).not.toContain("esc to re-enter")
776+
expect(guidanceRow).not.toContain("connection refused")
777+
})
778+
})

‎src/tui-opentui/provider-setup.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,15 @@ export async function runProviderSetup(
645645
})
646646

647647
function listHeight(): number {
648+
// This budget is a guess, not a derivation: it runs before `root` is
649+
// even constructed below, so there has been no layout pass yet and
650+
// nothing in OpenTUI to measure — Renderable.height and scrollHeight
651+
// only reflect the last completed layout, populated post-mount. -14
652+
// is a hand count of the chrome rows above and below the list (header,
653+
// intro, step, instruction, summary, statusLine, guidance, footer, and
654+
// padding) with slack for a wrapped label; it goes stale if that chrome
655+
// changes and nothing here will catch it. A shared, derived chrome
656+
// budget for this and shell.ts's picker is tracked separately.
648657
const rows = renderer.height || 24
649658
return Math.max(LIST_ROWS_MIN, Math.min(LIST_ROWS_MAX, rows - 14))
650659
}
@@ -669,25 +678,35 @@ export async function runProviderSetup(
669678
paddingRight: margin,
670679
})
671680

681+
// Every direct child of `root` needs flexShrink: 0, full stop — a plain
682+
// TextRenderable defaults to shrinkable, and a short terminal makes the
683+
// flex algorithm compress unprotected single-line rows into each other
684+
// (garbled overlapping text) instead of clipping the column from the
685+
// bottom. header/intro/step/instruction here, and statusLine/guidance/
686+
// footer further down, all needed this; it is not specific to one step.
672687
const header = new TextRenderable(renderer, {
673688
id: "provider-setup-header",
674689
content: `${PRODUCT_NAME.toLowerCase()} · setup`,
675690
fg: UI.inFlightBright,
691+
flexShrink: 0,
676692
})
677693
const intro = new TextRenderable(renderer, {
678694
id: "provider-setup-welcome",
679695
content: "connect an inference provider — switch later with /model",
680696
fg: UI.textDim,
697+
flexShrink: 0,
681698
})
682699
const step = new TextRenderable(renderer, {
683700
id: "provider-setup-step",
684701
content: "",
685702
fg: UI.action,
703+
flexShrink: 0,
686704
})
687705
const instruction = new TextRenderable(renderer, {
688706
id: "provider-setup-instruction",
689707
content: "",
690708
fg: UI.text,
709+
flexShrink: 0,
691710
})
692711

693712
const summary = new BoxRenderable(renderer, {
@@ -777,11 +796,13 @@ export async function runProviderSetup(
777796
id: "provider-setup-status",
778797
content: "",
779798
fg: UI.textDim,
799+
flexShrink: 0,
780800
})
781801
const guidance = new TextRenderable(renderer, {
782802
id: "provider-setup-guidance",
783803
content: "",
784804
fg: UI.textDim,
805+
flexShrink: 0,
785806
})
786807
const telemetry = new BoxRenderable(renderer, {
787808
id: "provider-setup-telemetry",
@@ -816,6 +837,7 @@ export async function runProviderSetup(
816837
id: "provider-setup-footer",
817838
content: "",
818839
fg: UI.textFaint,
840+
flexShrink: 0,
819841
})
820842

821843
root.add(header)

0 commit comments

Comments
 (0)