diff --git a/CHANGELOG.md b/CHANGELOG.md
index a621432b7c..4c7110ba4e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,8 @@ file is the human-readable summary of what changed and why.
## Unreleased
+### TUI
+
## 1.18.4-5
### TUI
diff --git a/DIVERGENCES.md b/DIVERGENCES.md
index a2325f9f49..1a7caed18e 100644
--- a/DIVERGENCES.md
+++ b/DIVERGENCES.md
@@ -124,6 +124,7 @@ from the squash message first, or use a plain merge.
| `packages/tui/src/component/use-connected.tsx`, `packages/tui/src/context/local.tsx` | use-connected.tsx: the provider predicate is extracted into an exported `isConnectedProvider`; `useConnected` now delegates to it (same logic). local.tsx: `fallbackModel`'s last resort is `sync.data.provider.find(isConnectedProvider) ?? sync.data.provider[0]` instead of a blind `sync.data.provider[0]` (covered by fork-only `test/component/use-connected.test.ts`, not tracked here). | The hosted Zen provider self-registers (free models need no auth) and is inserted ahead of config-only providers, so with no config `model` pin and empty recents (a fresh `codevhub install` — the pin was dropped in PR #142 / hub PR #221) the TUI defaulted to "Big Pickle · OpenCode Zen" and would send messages to upstream's hosted gateway instead of the configured one. Same predicate as `app.tsx`'s startup gate and the footer indicator; when no provider qualifies the old first-provider behavior is kept. | Keep the predicate export and the `find` fallback; take upstream's surrounding changes to either file. If upstream reworks `fallbackModel`, re-apply the connected-first selection to its last-resort branch. |
| `packages/tui/src/component/prompt/index.tsx` | The busy-state footer arm (`status().type !== "idle"`) also renders `props.hint` after the `esc interrupt` text, gated on `status().type !== "retry"` | The hidden-sidebar hint (see the `routes/session/index.tsx` hint row) only rendered in the footer `Switch`'s idle fallback arm, so it vanished for the whole duration of a running turn — exactly when todos are being created. Retry keeps its `space-between` error row uncluttered. | Keep the `{props.hint}` line in the busy `Match` arm (additive); take upstream's surrounding changes. |
| `packages/tui/src/component/prompt/index.tsx` | Width-tiered busy footer: module-level `BUSY_FOOTER_PALETTE_MIN_WIDTH` (108) and `BUSY_FOOTER_HINT_MIN_WIDTH` (88) drive a `showPaletteHint` memo (`status().type === "idle" \|\| width >= 108`) wrapping the ` commands` text and a `showBusyHint` memo (`width >= 88`) added to the busy arm's `props.hint` gate | The busy row carries spinner + interrupt hint + sidebar hint + context/cost; as the terminal narrows flexbox runs the gaps to zero (cramped but intact) and below ~85 columns the row wraps to two lines and clips the cost. Shedding by value keeps it on one line down to 70: the static palette hint goes first, then the modified-file count; the spinner and interrupt hint never go. | Keep both constants, both memos and the two `Show` gates; take upstream's surrounding changes. Re-measure the thresholds if segments are added to the row. |
+| `packages/tui/src/component/prompt/index.tsx` | The footer's right slot renders key hints and usage independently instead of upstream's either/or `Switch` (`usage()` else hints): module-level `FOOTER_HINTS_AND_USAGE_MIN_WIDTH` (120) drives a `usageMeta` memo (usage data, shown when `width >= 120` or `status().type !== "idle"`) and a `showKeyHints` memo (`!usageMeta() \|\| width >= 120`) replacing the inner `Switch`/`Match` pair with two sibling `Show` gates. Covered by `test/footer-width.view.test.tsx`. | Upstream shows the `shift+tab modes` / `tab agents` hints only while the session has no assistant output tokens, so one prompt permanently swaps them for the context/cost readout and the shortcuts stop being discoverable. A wide terminal fits both; narrower ones show the hints while idle (when you'd press them) and usage while busy (when it's changing). | Keep the constant, both memos and the two `Show` gates; take upstream's surrounding changes. Re-measure the threshold if segments are added to the row. |
| `packages/tui/src/component/prompt/index.tsx` | Removed the editor open-file label from the footer's right-side group and lifted the state behind it out of `Prompt`: the local `dismissedEditorSelectionKey` signal and the `editorContext`/`editorPath`/`editorSelectionLabel`/`editorFileLabel`/`editorFileLabelDisplay`/`editorContextLabelState` memos are gone, replaced by `useEditorLabel()` (fork-only `src/context/editor-label.tsx`) — `selection()` feeds the `prompt.editor_context.clear` `enabled` predicate and the submit-time editor part, `state()` replaces `editor.labelState()` there, and `dismiss()` replaces the local `dismissEditorContext`. The pure helpers `hasEditorRangeSelection`/`getEditorRangeLabel` moved to that context module and are imported back for `formatEditorContext`. | The label now lives in the sidebar footer (see its row above). The dismissal state had to move with it: the sidebar must label the *effective* selection — the one this file actually attaches on submit — or it would advertise context the prompt has dismissed and won't send. | Keep the deletions, the `useEditorLabel` import + its three call sites, and the helper import from `../../context/editor-label`; take upstream's surrounding changes. If upstream reworks the editor-context label, apply the change to `editorContextFileLabel` in the fork-only sidebar module rather than restoring the footer render. |
| `packages/tui/src/routes/session/index.tsx`, `packages/tui/src/component/prompt/index.tsx` | Left accent border removed entirely: the user-message block's `border={["left"]}`/`borderColor` (agent accent)/`customBorderChars` props and the prompt input box's two bordered boxes (main box with its `bottomLeft: "╹"` char, plus the height-1 `╹` terminator row) are replaced with `paddingLeft={1}`; the now-unused `borderHighlight` memo and the `tint`/`SplitBorder` imports are removed from the prompt (the `highlight`/`agentMetaAlpha` memos stay — they still color the agent name label); the fork's in-scrollbox banner box gains `paddingLeft={1}` so the banner/tips left edge lines up with the prompt/message blocks' background edge (the removed border glyph used to occupy that aligned column); `BlockTool` (expanded tool-output panels) likewise drops its `border={["left"]}`/`customBorderChars`/`borderColor` (the background-colored `┃` glyph over the panel background rendered as a stray vertical line on the panel's left edge) and bumps `paddingLeft` 2 → 3 to keep the content column | No vertical accent line next to sent messages, the prompt, or tool-output panels — removed rather than recolored to the background because background-colored glyphs render oddly on some terminals (e.g. PowerShell). `paddingLeft={1}` keeps the same 1-column offset, so text alignment with other transcript blocks is unchanged. | Keep our `paddingLeft={1}` boxes and the `borderHighlight`/`tint`/`SplitBorder` removals; take upstream's surrounding changes. If upstream adds new accent-colored left borders to these blocks, replace them with `paddingLeft={1}` the same way. |
| `packages/tui/src/component/spinner.tsx` | `SPINNER_FRAMES` swapped from cli-spinners "dots" (10 braille frames) to cli-spinners "sand" (35 braille frames, falling-sand animation); interval stays 80 ms | Fork visual identity for the shared `Spinner` component (in-progress tool/thinking rows, startup loading, session-list dialogs). | Keep our frames array; take upstream's surrounding changes. |
@@ -172,7 +173,7 @@ from the squash message first, or use a plain merge.
| `packages/app/src/context/language.tsx`, `packages/app/src/entry.tsx`, `packages/desktop/src/renderer/i18n/index.ts`, `packages/console/app/src/i18n/index.ts`, `packages/console/app/src/lib/language.ts`, `packages/console/app/src/component/footer.tsx`, `packages/stats/app/src/i18n.ts`, `packages/enterprise/src/app.tsx`, `packages/app/e2e/regression/session-timeline-locale-projection.spec.ts` | Locale registries trimmed to `en` + `es`: `Locale` types, LOCALES/LABEL/TAG/DOCS maps, dict loaders, accept-language matchers; enterprise/entry `zh` special cases now use `es`; console footer always links Discord (Feishu was zh-only); e2e locale-projection spec tests `es` ("Explorado") instead of de/ar | Fork ships English + Spanish only. | Keep our trimmed lists; take upstream's structural changes and re-trim any new locale entries. |
| `packages/tui/src/component/prompt/autocomplete.tsx`, `packages/app/src/components/prompt-input.tsx`, `packages/opencode/src/cli/cmd/run/footer.prompt.tsx`, `packages/core/src/filesystem/search.ts`, `packages/tui/test/fixture/tui-sdk.ts`, `packages/opencode/test/cli/run/footer.view.test.tsx`, `packages/core/test/filesystem/search.test.ts` | `@` mention autocomplete lists file/directory results before references/agents/MCP resources; TUI and run-CLI menus cap the leading files so up to 3 non-file matches stay in the first visible page; web app group rank reordered to recent → file → reference → agent → resource (extracted into fork-only `prompt-input/at-options.ts`). TUI and run-CLI file resources track menu visibility so opening on an empty query fetches (previously nothing refetched until the query changed); core's ripgrep-fallback `find` returns its first entries for an empty query to mirror fff. Tests: tui-sdk fixture's `FetchHandler` gains an optional `method` arg, `renderFooter` accepts `findFiles`/`agents` overrides + a mention-menu ordering test, core search tests cover the fallback's empty query (fork-only test files: `packages/tui/test/prompt-autocomplete.test.tsx`, `packages/app/test-browser/prompt-at-options.test.ts`, `packages/app/src/components/prompt-input/at-options{,.test}.ts`) | Files are the dominant `@` use case; they should rank first and appear on a bare `@`. | Keep our ordering/reserve logic, resource source tracking, empty-query branch, fixture/test additions; take upstream's surrounding changes. |
| `packages/slack/.gitignore`, `sdks/vscode/.gitignore` | Added the missing trailing newline (content unchanged) | Upstream's copies end without a newline; POSIX text files should end with one, and tools that concatenate or append to these files misbehave without it. | Whitespace-only: keep the trailing newline; take upstream's entries. |
-| `packages/tui/src/config/keybind.ts`, `packages/tui/src/component/prompt/index.tsx`, `packages/tui/src/app.tsx` | Permission-mode indicator + shift+tab cycle. keybind.ts: added `permission_mode_cycle: keybind("shift+tab", ...)` + its `permission.mode` CommandMap entry; `agent_cycle_reverse` default `"shift+tab"` → `"none"` (tab still cycles agents forward; previous-agent stays rebindable). prompt/index.tsx: the footer badge next to the agent name always renders in prompt mode — gray `manual` (`theme.textMuted`) in normal mode, red `auto` (`theme.error`) in auto mode (upstream only showed gray `auto`); the idle footer's shortcut hints gain a `shift+tab modes` text (via a `modeShortcut` `useCommandShortcut("permission.mode")` memo) left of `tab agents`, in the same no-usage `Match` arm. app.tsx: `"permission.mode"` added to `appBindingCommands` so the binding is gathered. | The permission mode is always visible (manual/auto) with auto in red as a danger signal, and shift+tab toggles it without opening the palette. | Keep the `permission_mode_cycle` definition + mapping, the `agent_cycle_reverse` `"none"` default, the badge `Show` block, the `modeShortcut` memo + hint text, and the `appBindingCommands` entry; take upstream's surrounding changes. If upstream reworks the auto badge or rebinds shift+tab, re-apply on top and re-audit the shift+tab conflict. |
+| `packages/tui/src/config/keybind.ts`, `packages/tui/src/component/prompt/index.tsx`, `packages/tui/src/app.tsx` | Permission-mode indicator + shift+tab cycle. keybind.ts: added `permission_mode_cycle: keybind("shift+tab", ...)` + its `permission.mode` CommandMap entry; `agent_cycle_reverse` default `"shift+tab"` → `"none"` (tab still cycles agents forward; previous-agent stays rebindable). prompt/index.tsx: the footer badge next to the agent name always renders in prompt mode — gray `manual` (`theme.textMuted`) in normal mode, red `auto` (`theme.error`) in auto mode (upstream only showed gray `auto`); the footer's shortcut hints gain a `shift+tab modes` text (via a `modeShortcut` `useCommandShortcut("permission.mode")` memo) left of `tab agents`, in the same `Show` gate (see the hints-and-usage row above for when that gate renders). app.tsx: `"permission.mode"` added to `appBindingCommands` so the binding is gathered. | The permission mode is always visible (manual/auto) with auto in red as a danger signal, and shift+tab toggles it without opening the palette. | Keep the `permission_mode_cycle` definition + mapping, the `agent_cycle_reverse` `"none"` default, the badge `Show` block, the `modeShortcut` memo + hint text, and the `appBindingCommands` entry; take upstream's surrounding changes. If upstream reworks the auto badge or rebinds shift+tab, re-apply on top and re-audit the shift+tab conflict. |
| `packages/core/src/filesystem/search.ts`, `packages/core/src/ripgrep/binary.ts`, `packages/core/src/filesystem.ts`, `packages/core/src/tool/read-filesystem.ts`, `packages/core/test/filesystem/search.test.ts`, `packages/core/test/location-filesystem.test.ts`, `packages/core/test/ripgrep-binary.test.ts`, `packages/core/test/preload.ts` | Windows file-search fallback robustness. search.ts: the background ripgrep index walk no longer dies silently (`Effect.orDie` in a forked fiber) — each attempt resets the accumulated file/directory state, failures log a warning ("file index scan failed, file search may be empty"), and the walk retries forever on a 1s exponential backoff capped at 30s spacing, so installing rg mid-session self-heals; directory entries are suffixed and detected with `"/"` instead of `path.sep` (both ripgrep and fff layers), and fallback `find` queries normalize `\` to `/` and collapse doubled slashes (directory tab-expansion appends `/` to an already-`/`-suffixed entry). binary.ts: `Effect.cached` on ripgrep binary resolution (which memoizes failure exits for the process lifetime) replaced with a success-only cache behind a semaphore, so a failed rg download (offline, blocked proxy) retries on the next use instead of bricking search until restart; the download tail tries GitHub first (each attempt bounded to 30s) and falls back to the CoDev landing-page mirror (`https://netmind.viettel.vn/codev/docs/code/downloads/rg---[.exe]`, a raw executable — no archive/extract — staged in a scoped temp dir then copied into place), via `Effect.catchCause` so a proxy block page (200 + HTML) that dies in `extract` as a defect still reaches the mirror. filesystem.ts `list` + read-filesystem.ts `list`: directory suffix `path.sep` → `"/"`; `fs.list` also `/`-normalizes `path.relative` output. Tests: search.test.ts pins `"/"`-suffixed directories, adds query-normalization coverage, and a stub-Ripgrep scan-retry test asserting the partial first walk is discarded; location-filesystem.test.ts pins `"src/"`; ripgrep-binary.test.ts adds mocked-HTTP coverage of the fallback order, success caching, and failure non-caching; preload.ts redirects `XDG_CACHE_HOME` to a fresh temp dir so no test can read or clobber the developer's real `~/.cache/codev`. | On Windows fff is disabled by default (upstream `e4300e9b74`), so the `@` mention popup and file search depend entirely on the ripgrep fallback; a missing `rg.exe` plus one failed download left the index permanently empty with no user-visible signal (the CI-workflows row's choco preinstall papered over the same swallowed failure). `\`-suffixed directory entries also broke `endsWith("/")` directory-mime detection in the run CLI and poisoned drill-down queries with backslashes that can never match the `/`-normalized index. | Keep our retry/reset scan, success-only binary cache, `"/"` suffixes, and query normalization; take upstream's surrounding changes. If upstream reworks the fallback or re-enables fff on win32, preserve the invariants: entry paths are `/`-normalized with `/`-suffixed directories on every platform, index-scan failures are logged and retried, a failed binary download is never cached, and the GitHub download keeps its CoDev-mirror fallback — the mirror filename scheme must match what codev-landing-page hosts under `public/docs/code/downloads` (and codevhub's `RG_VERSION` pin in its `src/lib/ripgrep.ts`); bump all three together when changing `VERSION`. |
| `packages/tui/src/context/permission.tsx`, `packages/tui/src/app.tsx`, `packages/tui/src/ui/dialog-confirm.tsx`, `packages/tui/src/ui/toast.tsx` | Auto-approve consent gate. permission.tsx: mode always starts `"normal"` (upstream initialized it from `args.auto`); `toggle()` replaced by `set(mode)` plus a per-run `consented` flag + `consent()`. app.tsx: shared `enableAutoApprove()` (title in the `AUTO_APPROVE_TITLE` module const, body from an `autoApproveMessage` accessor, and a shorter one-line `autoApproveToast` accessor for the toast, which only has a few seconds on screen; both redden the word "dangerous" with `theme.error` and are passed unevaluated because renderables can only be created during render) shows a `DialogConfirm` opened on cancel and, on confirm, a warning toast under the same title, then sets the mode; both entry points funnel through it — an `onMount` when `args.auto` is set, and the `permission.mode` command (shift+tab / palette), which enables via the gate and disables directly. Consent is asked once per run; later toggles are silent. dialog-confirm.tsx: optional `defaultChoice` prop + 5th `DialogConfirm.show` arg selecting the initially-active button (omitted keeps upstream's confirm-first default); `message` widened to `JSX.Element \| (() => JSX.Element)` and called at the render site. toast.tsx: same `message` widening, resolved by a `message()` helper in `Toast()` (fork-only test file: `packages/tui/test/auto-approve-consent.view.test.tsx`). | Auto-approve is dangerous, so `--auto` (and its hidden aliases) and the first shift+tab into auto require explicit confirmation; declining or dismissing the dialog stays in manual mode, opening on cancel keeps a stray return from granting it, and the risk word is red like the `auto` badge. | Keep the consent flag + gate flow, the `toggle()` removal, the `defaultChoice` prop, and the accessor-aware `message` on both dialog and toast; take upstream's surrounding changes. If upstream adds new writers of the permission mode, route their enable path through `enableAutoApprove()`. |
diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx
index a0fd42a41a..9b6787b15b 100644
--- a/packages/tui/src/component/prompt/index.tsx
+++ b/packages/tui/src/component/prompt/index.tsx
@@ -73,6 +73,13 @@ const FOOTER_FRAMES = ["▰▱▱▱▱▱▱", "▰▰▱▱▱▱▱", "▰▰
const BUSY_FOOTER_PALETTE_MIN_WIDTH = 108
const BUSY_FOOTER_HINT_MIN_WIDTH = 88
+// The right footer slot carries the shift+tab/tab key hints and, once the
+// session has token data, the context/cost readout. A wide terminal fits both;
+// below this the slot shows the hints while idle and usage while busy, so the
+// shortcuts stay discoverable after the first response instead of vanishing
+// for the rest of the session.
+const FOOTER_HINTS_AND_USAGE_MIN_WIDTH = 120
+
export type PromptProps = {
sessionID?: string
visible?: boolean
@@ -1342,6 +1349,14 @@ export function Prompt(props: PromptProps) {
() => status().type === "idle" || dimensions().width >= BUSY_FOOTER_PALETTE_MIN_WIDTH,
)
const showBusyHint = createMemo(() => dimensions().width >= BUSY_FOOTER_HINT_MIN_WIDTH)
+ const usageMeta = createMemo(() => {
+ const item = usage()
+ if (!item) return
+ if (dimensions().width >= FOOTER_HINTS_AND_USAGE_MIN_WIDTH) return item
+ if (status().type !== "idle") return item
+ return undefined
+ })
+ const showKeyHints = createMemo(() => !usageMeta() || dimensions().width >= FOOTER_HINTS_AND_USAGE_MIN_WIDTH)
const maxHeight = createMemo(() => tuiConfig.prompt?.max_height ?? Math.max(6, Math.floor(dimensions().height / 3)))
const moveLabelWidth = createMemo(() => Math.max(12, Math.min(44, dimensions().width - 48)))
@@ -1659,23 +1674,21 @@ export function Prompt(props: PromptProps) {
-
-
- {(item) => (
-
- {[item().context, item().cost].filter(Boolean).join(" · ")}
-
- )}
-
-
-
- {modeShortcut()} modes
+
+ {(item) => (
+
+ {[item().context, item().cost].filter(Boolean).join(" · ")}
-
- {agentShortcut()} agents
-
-
-
+ )}
+
+
+
+ {modeShortcut()} modes
+
+
+ {agentShortcut()} agents
+
+
{paletteShortcut()} commands
diff --git a/packages/tui/test/footer-width.view.test.tsx b/packages/tui/test/footer-width.view.test.tsx
index c6cc999840..031c2477d0 100644
--- a/packages/tui/test/footer-width.view.test.tsx
+++ b/packages/tui/test/footer-width.view.test.tsx
@@ -39,8 +39,10 @@ const diffs = Array.from({ length: 12 }, (_, index) => ({
// The busy footer carries spinner + interrupt hint + modified-file count +
// context/cost. It sheds the palette hint below 108 columns and the file count
-// below 88 so the row never wraps; the interrupt hint always survives.
-async function busyFooterRow(width: number) {
+// below 88 so the row never wraps; the interrupt hint always survives. The
+// right slot fits both the key hints and context/cost from 120 columns up;
+// narrower it shows the hints while idle and usage while busy.
+async function footerRow(width: number, opts?: { idle?: boolean }) {
const setup = await createTestRenderer({ width, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
@@ -122,21 +124,25 @@ async function busyFooterRow(width: number) {
)
await ready
await waitForFrame(setup, (frame) => frame.includes("Baked for"))
- const busy: GlobalEvent = {
- directory,
- project: "proj_test",
- payload: {
- id: "evt_busy",
- type: "session.status",
- properties: { sessionID: session.id, status: { type: "busy" } },
- },
+ if (!opts?.idle) {
+ const busy: GlobalEvent = {
+ directory,
+ project: "proj_test",
+ payload: {
+ id: "evt_busy",
+ type: "session.status",
+ properties: { sessionID: session.id, status: { type: "busy" } },
+ },
+ }
+ events.emit(busy)
}
- events.emit(busy)
- const frame = await waitForFrame(setup, (f) => f.includes("interrupt"))
+ // Idle rows anchor on the always-shown "tab agents" hint instead.
+ const marker = opts?.idle ? "agents" : "interrupt"
+ const frame = await waitForFrame(setup, (f) => f.includes(marker))
const rows = frame.split("\n").map((line) => line.replace(/\s+$/, ""))
process.emit("SIGHUP")
await task
- return rows[rows.findLastIndex((line) => line.includes("interrupt"))]
+ return rows[rows.findLastIndex((line) => line.includes(marker))]
} finally {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
mock.restore()
@@ -144,27 +150,52 @@ async function busyFooterRow(width: number) {
}
test("busy footer keeps every hint on a wide terminal", async () => {
- const row = await busyFooterRow(120)
+ const row = await footerRow(120)
expect(row).toContain("interrupt")
expect(row).toContain("modified files")
expect(row).toContain("commands")
+ // Wide enough for the key hints and the usage readout together.
+ expect(row).toContain("agents")
+ expect(row).toContain("modes")
+ expect(row).toContain("$123.45")
expect(row.length).toBeLessThanOrEqual(120)
}, 30_000)
test("busy footer sheds the palette hint at 100 columns", async () => {
- const row = await busyFooterRow(100)
+ const row = await footerRow(100)
expect(row).toContain("interrupt")
expect(row).toContain("modified files")
expect(row).not.toContain("commands")
+ // Narrow + busy: usage wins the right slot, the key hints go.
+ expect(row).not.toContain("agents")
+ expect(row).toContain("$123.45")
expect(row.length).toBeLessThanOrEqual(100)
}, 30_000)
test("busy footer sheds the modified-file count at 80 columns", async () => {
- const row = await busyFooterRow(80)
+ const row = await footerRow(80)
expect(row).toContain("interrupt")
expect(row).not.toContain("modified files")
expect(row).not.toContain("commands")
+ expect(row).not.toContain("agents")
// Still one line: the row that carries the interrupt hint also carries usage.
expect(row).toContain("$123.45")
expect(row.length).toBeLessThanOrEqual(80)
}, 30_000)
+
+test("idle footer shows key hints and usage on a wide terminal", async () => {
+ const row = await footerRow(120, { idle: true })
+ expect(row).toContain("modes")
+ expect(row).toContain("agents")
+ expect(row).toContain("commands")
+ expect(row).toContain("$123.45")
+ expect(row.length).toBeLessThanOrEqual(120)
+}, 30_000)
+
+test("idle footer keeps key hints over usage at 80 columns", async () => {
+ const row = await footerRow(80, { idle: true })
+ expect(row).toContain("modes")
+ expect(row).toContain("agents")
+ expect(row).not.toContain("$123.45")
+ expect(row.length).toBeLessThanOrEqual(80)
+}, 30_000)