Public: anyone can read this; the crew can change it.
The terminal's limbs screen says what the planner changed on a re-planned limb: each old issue and what replaced it, as the milestones page does #7470
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7448-the-terminal-s-limbs-screen-says-what-th → main 2 commits fork
2 files
changed +300
−8
src/tui/screens/limbs.ts +116 −8
| @@ -22,6 +22,13 @@ | ||
| 22 | 22 | * one push follow. A person only; an agent is told so. A watched row reads |
| 23 | 23 | * "watching". |
| 24 | 24 | * |
| 25 | * Under a limb the planner has answered, the screen says what it changed | |
| 26 | * (owner facts only, as the page): `Re-planned <time ago>` and a line per | |
| 27 | * split, `#old title → #new, #new`. `tab` moves the cursor onto those lines, | |
| 28 | * `j`/`k` scroll them (the header counts any out of view), `enter` opens the | |
| 29 | * new issue, or a choice among them when the line has several, `tab` or `esc` | |
| 30 | * goes back. The block takes at most half the screen below the header. | |
| 31 | * | |
| 25 | 32 | * `enter` on a limb that has limbs of its own opens them, so a milestone's |
| 26 | 33 | * sub-limbs can be moved among themselves (the `under` of the action). |
| 27 | 34 | * Subscribed to the bus while on the stack, so a move made on the web, or in |
| @@ -29,7 +36,7 @@ | ||
| 29 | 36 | */ |
| 30 | 37 | |
| 31 | 38 | import { undoFleetOp } from "../../actions/fleet.ts"; |
| 32 | import { previewReplan, replanLimb, takeBackLimb } from "../../actions/limb_replan.ts"; | |
| 39 | import { previewReplan, replanLimb, replanResult, takeBackLimb } from "../../actions/limb_replan.ts"; | |
| 33 | 40 | import { IN_HAND } from "../../actions/plan_words.ts"; |
| 34 | 41 | import { MILESTONE_WATCH_PERSON_ONLY } from "../../actions/board_words.ts"; |
| 35 | 42 | import { watchMilestone } from "../../actions/watches.ts"; |
| @@ -44,12 +51,16 @@ import { fill, len, split } from "../ui/layout.ts"; | ||
| 44 | 51 | import { style } from "../term/style.ts"; |
| 45 | 52 | import { truncate } from "../term/width.ts"; |
| 46 | 53 | import type { Session } from "../session.ts"; |
| 54 | import { issueScreen } from "./issue.ts"; | |
| 55 | import { local } from "./where.ts"; | |
| 47 | 56 | import { |
| 48 | 57 | ListState, |
| 58 | ago, | |
| 49 | 59 | confirmScreen, |
| 50 | 60 | drawList, |
| 51 | 61 | hitList, |
| 52 | 62 | live, |
| 63 | pickerScreen, | |
| 53 | 64 | plural, |
| 54 | 65 | runAsync, |
| 55 | 66 | type ListRow, |
| @@ -77,6 +88,39 @@ export function spendLines(spend: NonNullable<Milestone["spend"]>): string[] { | ||
| 77 | 88 | ]; |
| 78 | 89 | } |
| 79 | 90 | |
| 91 | /** The most split lines drawn at once; `j` and `k` scroll the rest into view. */ | |
| 92 | const SPLITS_SHOWN = 4; | |
| 93 | ||
| 94 | type Splits = NonNullable<ReturnType<typeof replanResult>>; | |
| 95 | ||
| 96 | /** | |
| 97 | * What the planner changed on a limb, as the lines under the list: a header, | |
| 98 | * then `#old title → #new, #new` per split, a window of at most `room - 1` of | |
| 99 | * them that follows `at`. The header says how many are out of view. Nothing | |
| 100 | * when there is no room for a header and a line. | |
| 101 | */ | |
| 102 | export function replanLines( | |
| 103 | r: Splits, | |
| 104 | room: number, | |
| 105 | at = 0, | |
| 106 | ): { lines: string[]; start: number; shown: number } { | |
| 107 | const n = r.splits.length; | |
| 108 | const shown = Math.min(n, SPLITS_SHOWN, room - 1); | |
| 109 | // Too little room for a line: say so in the header rather than draw nothing. | |
| 110 | if (shown < 1) | |
| 111 | return { | |
| 112 | lines: room < 1 ? [] : [`Re-planned ${ago(r.at)} · ${plural(n, "split")}, too little room to list`], | |
| 113 | start: 0, | |
| 114 | shown: 0, | |
| 115 | }; | |
| 116 | const start = Math.max(0, Math.min(at - (shown - 1), n - shown)); | |
| 117 | const more = n > shown ? ` · +${n - shown} more` : ""; | |
| 118 | const lines = [`Re-planned ${ago(r.at)}${more}`]; | |
| 119 | for (const s of r.splits.slice(start, start + shown)) | |
| 120 | lines.push(`#${s.number} ${s.title} → ${s.into.map((p) => `#${p.number}`).join(", ")}`); | |
| 121 | return { lines, start, shown }; | |
| 122 | } | |
| 123 | ||
| 80 | 124 | const line = (n: number, title: string): string => `#${n} ${title}`; |
| 81 | 125 | |
| 82 | 126 | /** What a preview says before anything is written, as dialog lines. */ |
| @@ -123,6 +167,12 @@ export function limbsScreen( | ||
| 123 | 167 | let last: string | undefined; |
| 124 | 168 | /** The limbs with the planner, by issue id (#761). */ |
| 125 | 169 | let withPlanner = new Set<string>(); |
| 170 | /** What the planner changed on each answered limb here, by issue number; owner facts only. */ | |
| 171 | let replanned = new Map<number, Splits>(); | |
| 172 | /** The split line the cursor is on while `tab` has moved it under the list; else undefined. */ | |
| 173 | let splitAt: number | undefined; | |
| 174 | /** How many split lines the last render had room to draw; `tab` needs one. */ | |
| 175 | let splitsDrawn = 0; | |
| 126 | 176 | /** The milestones here the viewer watches, by issue id (#7236). */ |
| 127 | 177 | let watching = new Set<string>(); |
| 128 | 178 | // Read at use, not at open: a repository's admin can change under a session. |
| @@ -134,6 +184,8 @@ export function limbsScreen( | ||
| 134 | 184 | limbs = []; |
| 135 | 185 | spends = new Map(); |
| 136 | 186 | paces = new Map(); |
| 187 | replanned = new Map(); | |
| 188 | splitAt = undefined; | |
| 137 | 189 | loaded = true; |
| 138 | 190 | ctx.invalidate(); |
| 139 | 191 | return; |
| @@ -143,8 +195,12 @@ export function limbsScreen( | ||
| 143 | 195 | withPlanner = new Set(pendingReplans(f.db, repo).map((r) => r.limbId)); |
| 144 | 196 | spends = new Map(); |
| 145 | 197 | paces = new Map(); |
| 198 | replanned = new Map(); | |
| 146 | 199 | watching = new Set(); |
| 200 | const owner = canSeeOwnerFacts(f.db, repo, f.user); | |
| 147 | 201 | for (const l of limbs) { |
| 202 | const r = owner ? replanResult(f.app, repo, l.issue.id) : undefined; | |
| 203 | if (r !== undefined && r.splits.length > 0) replanned.set(l.issue.number, r); | |
| 148 | 204 | const m = plan.milestone(l.issue.number); |
| 149 | 205 | if (m === undefined) continue; |
| 150 | 206 | if (f.user?.kind === "user" && isWatching(f.db, f.user.id, m.issue.id)) |
| @@ -158,9 +214,15 @@ export function limbsScreen( | ||
| 158 | 214 | if (at >= 0) state.index = at; |
| 159 | 215 | } |
| 160 | 216 | state.clamp(limbs.length); |
| 217 | splitAt = undefined; | |
| 161 | 218 | ctx.invalidate(); |
| 162 | 219 | }; |
| 163 | 220 | |
| 221 | const splitsHere = (): Splits["splits"] => { | |
| 222 | const l = current(); | |
| 223 | return (l === undefined ? undefined : replanned.get(l.issue.number))?.splits ?? []; | |
| 224 | }; | |
| 225 | ||
| 164 | 226 | const current = (): Limb | undefined => limbs[state.index]; |
| 165 | 227 | |
| 166 | 228 | const move = (action: "raise" | "lower"): void => { |
| @@ -312,7 +374,8 @@ export function limbsScreen( | ||
| 312 | 374 | const moves = may() && limbs.length > 1; |
| 313 | 375 | return [ |
| 314 | 376 | binding(["j", "k"], "move cursor"), |
| 315 | binding(["enter"], "open"), | |
| 377 | binding(["enter"], splitAt === undefined ? "open" : "open the new issue"), | |
| 378 | binding(["tab"], splitAt === undefined ? "splits" : "limbs", { disabled: splitsDrawn === 0 }), | |
| 316 | 379 | binding(["r"], "raise", { disabled: !moves }), |
| 317 | 380 | binding(["l"], "lower", { disabled: !moves }), |
| 318 | 381 | binding(["p"], withPlanner.has(current()?.issue.id ?? "") ? "take back" : "re-plan", { disabled: !may() || current() === undefined }), |
| @@ -329,10 +392,19 @@ export function limbsScreen( | ||
| 329 | 392 | const c = ctx.theme.c; |
| 330 | 393 | const spend = current() === undefined ? undefined : spends.get(current()!.issue.number); |
| 331 | 394 | const pace = current() === undefined ? undefined : paces.get(current()!.issue.number); |
| 395 | const answered = current() === undefined ? undefined : replanned.get(current()!.issue.number); | |
| 396 | const fixed = (pace === undefined ? 0 : 1) + (spend ? 2 : 0); | |
| 397 | // The foot never takes more than half of what the header leaves. | |
| 398 | const block = | |
| 399 | answered === undefined | |
| 400 | ? { lines: [], start: 0, shown: 0 } | |
| 401 | : replanLines(answered, Math.max(0, ((area.h - 1) >> 1) - fixed), splitAt); | |
| 402 | splitsDrawn = block.shown; | |
| 403 | if (block.shown === 0) splitAt = undefined; | |
| 332 | 404 | const [head, list, foot] = split(area, "v", [ |
| 333 | 405 | len(1), |
| 334 | 406 | fill(1), |
| 335 | len((pace === undefined ? 0 : 1) + (spend ? 2 : 0)), | |
| 407 | len(fixed + block.lines.length), | |
| 336 | 408 | ]); |
| 337 | 409 | const note = !loaded |
| 338 | 410 | ? "Loading…" |
| @@ -341,14 +413,50 @@ export function limbsScreen( | ||
| 341 | 413 | : `${plural(limbs.length, "limb")} in the order the crew takes ready work${may() ? "" : " · you can read this order, not change it"}`; |
| 342 | 414 | s.text(head!.x, head!.y, truncate(note, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 343 | 415 | drawList(s, list!, c, state, rows()); |
| 344 | const lines = [...(pace === undefined ? [] : [pace]), ...(spend === undefined ? [] : spendLines(spend))]; | |
| 345 | if (lines.length > 0) | |
| 346 | lines.forEach((text, n) => | |
| 347 | s.text(foot!.x, foot!.y + n, truncate(text, foot!.w), style(c.mutedFg, c.bg), foot!.x + foot!.w), | |
| 348 | ); | |
| 416 | const lines = [ | |
| 417 | ...(pace === undefined ? [] : [pace]), | |
| 418 | ...(spend === undefined ? [] : spendLines(spend)), | |
| 419 | ...block.lines, | |
| 420 | ]; | |
| 421 | // The split lines are the last ones; the header sits above them. | |
| 422 | const first = lines.length - block.shown; | |
| 423 | lines.forEach((text, n) => { | |
| 424 | const on = splitAt !== undefined && n === first + splitAt - block.start; | |
| 425 | const cell = on ? style(c.fg, c.bg, 1) : style(c.mutedFg, c.bg); | |
| 426 | s.text(foot!.x, foot!.y + n, truncate(on ? `> ${text}` : text, foot!.w), cell, foot!.x + foot!.w); | |
| 427 | }); | |
| 349 | 428 | hitList(ctx, list!, state, limbs.length, { open }); |
| 350 | 429 | }, |
| 351 | 430 | key(e) { |
| 431 | const parts = splitsHere(); | |
| 432 | if (splitAt !== undefined && !e.ctrl && !e.alt) { | |
| 433 | if (e.key === "j" || e.key === "down") splitAt = Math.min(parts.length - 1, splitAt + 1); | |
| 434 | else if (e.key === "k" || e.key === "up") splitAt = Math.max(0, splitAt - 1); | |
| 435 | else if (e.key === "enter") { | |
| 436 | const into = parts[splitAt]?.into ?? []; | |
| 437 | const show = (number: number): void => | |
| 438 | ctx.push(issueScreen(f, ctx, local(f, repo), number)); | |
| 439 | if (into.length === 1) show(into[0]!.number); | |
| 440 | else if (into.length > 1) | |
| 441 | ctx.push( | |
| 442 | pickerScreen(ctx, screen, { | |
| 443 | title: `#${parts[splitAt]!.number} became`, | |
| 444 | items: into.map((p) => ({ id: String(p.number), label: `#${p.number} ${p.title}` })), | |
| 445 | onPick: ([chosen]) => { | |
| 446 | if (chosen !== undefined) show(Number(chosen)); | |
| 447 | }, | |
| 448 | }), | |
| 449 | ); | |
| 450 | } else if (e.key === "tab" || e.key === "esc") splitAt = undefined; | |
| 451 | else return false; | |
| 452 | ctx.invalidate(); | |
| 453 | return true; | |
| 454 | } | |
| 455 | if (e.key === "tab" && !e.ctrl && !e.alt && parts.length > 0) { | |
| 456 | splitAt = 0; | |
| 457 | ctx.invalidate(); | |
| 458 | return true; | |
| 459 | } | |
| 352 | 460 | if (state.key(e, limbs.length)) return true; |
| 353 | 461 | if (e.ctrl || e.alt) return false; |
| 354 | 462 | if (e.key === "enter") open(); |
test/tui-limbs-replanned.test.ts added +184
| @@ -0,0 +1,184 @@ | ||
| 1 | /** | |
| 2 | * The limbs screen says what the planner changed on a re-planned limb (#7448): | |
| 3 | * `Re-planned <time ago>` and one line per split, as the milestones page does, | |
| 4 | * and Enter on a split line opens the new issue. Owner facts only. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createApp, type App } from "../src/app.ts"; | |
| 11 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 12 | import { linkIssue } from "../src/actions/dependencies.ts"; | |
| 13 | import { openIssue, setIssueState } from "../src/actions/issues.ts"; | |
| 14 | import { replanLimb } from "../src/actions/limb_replan.ts"; | |
| 15 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 16 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 17 | import { getIssueById, type Issue } from "../src/core/issues.ts"; | |
| 18 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 19 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 20 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 21 | import { ensureDirectionIssue } from "../src/crew/autonomy.ts"; | |
| 22 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 23 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 24 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 25 | import { quietLogs } from "./helpers/globals.ts"; | |
| 26 | import { settleApp } from "./helpers/tui.ts"; | |
| 27 | ||
| 28 | quietLogs(); | |
| 29 | ||
| 30 | describe("the limbs screen after a re-plan", () => { | |
| 31 | let dataDir: string; | |
| 32 | let app: App; | |
| 33 | let alice: User; | |
| 34 | let bob: User; | |
| 35 | let repo: Repo; | |
| 36 | let limb: Issue; | |
| 37 | let leaves: Issue[]; | |
| 38 | let atlas: User; | |
| 39 | let fresh: () => Repo; | |
| 40 | const current = (i: Issue) => getIssueById(app.db, i.id)!; | |
| 41 | const open = (title: string, by: User): Issue => { | |
| 42 | const made = openIssue(app, by, fresh(), { title }); | |
| 43 | if (!made.ok) throw new Error(String(made.error)); | |
| 44 | return made.value; | |
| 45 | }; | |
| 46 | ||
| 47 | function boot(user?: User, rows = 32) { | |
| 48 | const terminal = headlessTerminal(120, rows); | |
| 49 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 50 | const { app: tui } = createForgeApp(app, identity, { terminal, exitHooks: false }); | |
| 51 | tui.start(); | |
| 52 | const press = async (...keys: string[]) => { | |
| 53 | for (const key of keys) tui.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false }); | |
| 54 | await settleApp(tui); | |
| 55 | }; | |
| 56 | const type = async (text: string) => press(...text.split("")); | |
| 57 | const toLimbs = async () => { | |
| 58 | await settleApp(tui); | |
| 59 | tui.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false }); | |
| 60 | await settleApp(tui); | |
| 61 | await type("Milestones in a repository"); | |
| 62 | await press("enter"); | |
| 63 | await type("alice/engine"); | |
| 64 | await press("enter"); | |
| 65 | }; | |
| 66 | return { text: () => tui.screen.toText(), press, toLimbs, stop: () => tui.stop() }; | |
| 67 | } | |
| 68 | ||
| 69 | beforeAll(async () => { | |
| 70 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-replanned-")); | |
| 71 | app = createApp({ dataDir, port: 0 }); | |
| 72 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) { | |
| 73 | const made = await createUser(app.db, { | |
| 74 | origin: "seed", | |
| 75 | trusted: true, | |
| 76 | username, | |
| 77 | email: `${username}@forge.local`, | |
| 78 | password: "a-good-passphrase", | |
| 79 | adminIfFirst, | |
| 80 | }); | |
| 81 | if (!made.ok) throw new Error(made.error); | |
| 82 | } | |
| 83 | alice = getUserByName(app.db, "alice")!; | |
| 84 | bob = getUserByName(app.db, "bob")!; | |
| 85 | const made = await createRepo(app, alice, { name: "engine", visibility: "public" }); | |
| 86 | if (!made.ok) throw new Error(String(made.error)); | |
| 87 | repo = made.value; | |
| 88 | fresh = () => getRepoById(app.db, repo.id)!; | |
| 89 | const agent = await createAgent(app, alice, { username: "atlas" }); | |
| 90 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 91 | expect(addToCrew(app, alice, repo, "atlas").ok).toBe(true); | |
| 92 | atlas = getUserByName(app.db, "atlas")!; | |
| 93 | setCrewRole(app.db, repo.id, atlas.id, "planner"); | |
| 94 | const set = await updateRepoSettings(app, alice, repo, { | |
| 95 | autonomy: "autonomous", | |
| 96 | autonomyDirection: "Keep the engine small.", | |
| 97 | autonomyCeilingUsd: 100, | |
| 98 | autonomyBy: alice.id, | |
| 99 | }); | |
| 100 | if (!set.ok) throw new Error(JSON.stringify(set.error)); | |
| 101 | limb = open("Search", alice); | |
| 102 | leaves = ["Index titles", "Index bodies", "Rank hits", "Highlight hits", "Cache hits", "Page hits"].map((t) => open(t, alice)); | |
| 103 | for (const leaf of leaves) linkIssue(app, alice, fresh(), current(limb), current(leaf)); | |
| 104 | }); | |
| 105 | ||
| 106 | afterAll(async () => { | |
| 107 | app.close(); | |
| 108 | await rm(dataDir, { recursive: true, force: true }); | |
| 109 | }); | |
| 110 | ||
| 111 | test("a limb not re-planned draws no answer, and one the planner has answered does", async () => { | |
| 112 | const before = boot(alice); | |
| 113 | await before.toLimbs(); | |
| 114 | expect(before.text()).toContain("Search"); | |
| 115 | expect(before.text()).not.toContain("Re-planned"); | |
| 116 | before.stop(); | |
| 117 | ||
| 118 | const direction = ensureDirectionIssue(app, alice, fresh())!; | |
| 119 | expect(replanLimb(app, alice, fresh(), limb.number).ok).toBe(true); | |
| 120 | await Bun.sleep(5); | |
| 121 | const larger = open("Search over titles and bodies", atlas); | |
| 122 | const other = open("Search ranking", atlas); | |
| 123 | const close = (i: Issue, into: number[]) => | |
| 124 | setIssueState(app, atlas, fresh(), current(i), "closed", { reason: "split", into }); | |
| 125 | expect(close(leaves[0]!, [larger.number, other.number]).ok).toBe(true); | |
| 126 | for (const leaf of leaves.slice(1)) expect(close(leaf, [larger.number]).ok).toBe(true); | |
| 127 | const run = createRun(app.db, { | |
| 128 | repoId: repo.id, | |
| 129 | kind: "agent", | |
| 130 | issueId: direction.id, | |
| 131 | actorId: atlas.id, | |
| 132 | reason: "assigned", | |
| 133 | }); | |
| 134 | claimRun(app.db, run.id); | |
| 135 | finishRun(app.db, run.id, { state: "ok", summary: "re-planned" }); | |
| 136 | ||
| 137 | const t = boot(alice); | |
| 138 | await t.toLimbs(); | |
| 139 | expect(t.text()).toMatch(/Re-planned (just now|\d+\w ago)/); | |
| 140 | expect(t.text()).toContain(`#${leaves[0]!.number} Index titles → #${larger.number}, #${other.number}`); | |
| 141 | expect(t.text()).toContain(`#${leaves[1]!.number} Index bodies → #${larger.number}`); | |
| 142 | // Six splits, four drawn: the header says two are out of view. | |
| 143 | expect(t.text()).toContain("+2 more"); | |
| 144 | expect(t.text()).not.toContain("Page hits →"); | |
| 145 | // Tab onto the split lines, down to the second, Enter opens its one new issue. | |
| 146 | await t.press("tab", "j", "enter"); | |
| 147 | expect(t.text()).toContain("Search over titles and bodies"); | |
| 148 | expect(t.text()).not.toContain("Re-planned"); | |
| 149 | await t.press("esc"); | |
| 150 | // Enter on the line with two new issues asks which; the second opens. | |
| 151 | expect(t.text()).toContain("Re-planned"); | |
| 152 | await t.press("k", "enter"); | |
| 153 | expect(t.text()).toContain(`#${larger.number} Search over titles and bodies`); | |
| 154 | expect(t.text()).toContain(`#${other.number} Search ranking`); | |
| 155 | await t.press("j", "enter"); | |
| 156 | expect(t.text()).toContain("Search ranking"); | |
| 157 | await t.press("esc"); | |
| 158 | // Scrolling down brings the hidden splits into view; tab goes back to the limbs. | |
| 159 | await t.press("j", "j", "j", "j", "j"); | |
| 160 | expect(t.text()).toContain("Page hits →"); | |
| 161 | await t.press("tab"); | |
| 162 | expect(t.text()).not.toContain("> #"); | |
| 163 | t.stop(); | |
| 164 | }); | |
| 165 | ||
| 166 | test("a short terminal keeps the list and says splits are out of view", async () => { | |
| 167 | const t = boot(alice, 10); | |
| 168 | await t.toLimbs(); | |
| 169 | expect(t.text()).toContain("1. #"); | |
| 170 | expect(t.text()).toContain("6 splits, too little room"); | |
| 171 | t.stop(); | |
| 172 | }); | |
| 173 | ||
| 174 | test("a reader without owner facts sees none of it", async () => { | |
| 175 | const t = boot(bob); | |
| 176 | await t.toLimbs(); | |
| 177 | expect(t.text()).toContain("Search"); | |
| 178 | expect(t.text()).not.toContain("Re-planned"); | |
| 179 | expect(t.text()).not.toContain("→"); | |
| 180 | await t.press("tab"); | |
| 181 | expect(t.text()).not.toContain("> #"); | |
| 182 | t.stop(); | |
| 183 | }); | |
| 184 | }); |
new above
offline