Subagents: Min and Max, one range with two handles, counting this agent (harness spec 3.3.0)
ci / check (push) Waiting to run

/effort's Subagents row and /team size are a two-handle range from 1 to the cap (left/right move
the handle in focus, tab the other, enter sets both); the dialog gains a range row. Every text says
Min and Max, never fewest/most, and that the count includes this agent: "Subagents: Min 1, Max 4 -
1 is this agent alone". /settings shows the same beside agents.min / agents.max; config schema
and the bounds refusal likewise. CHANGELOG in the 2.0.0-beta.5 section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-09 18:09:31 +00:00
1 parent 3a206959b6
commit 6a0e0ed145
11 files changed
+183 -42

No files matched your search

+9 -3
View File
@@ -28,9 +28,15 @@ and `/context` shows where the window goes the way the side panel of the other a
server that is not on its one-model server. When the session's model cannot serve its own server that is not on its one-model server. When the session's model cannot serve its own
subagents, the agent is told so in its first note, and `/effort`, `/team size` and `/team` say it subagents, the agent is told so in its first note, and `/effort`, `/team size` and `/team` say it
beside the setting. beside the setting.
- **"Subagents" is the word** for the setting (fewest and most agents at work, the lead included): - **"Subagents" is the word** for the setting, with **Min** and **Max**: the row beside the effort
the row beside the effort in `/effort`, `/team size`, `/settings`. "Helper" is gone. The status in `/effort`, `/team size`, `/settings`. "Helper" is gone. The status bar reads
bar reads `agents 3/4 · 2 subagents working` while there is a team. `agents 3/4 · 2 subagents working` while there is a team.
- **The numbers count this agent**: `Subagents: Min 1, Max 4 — 1 is this agent alone`. Max 1 is
the agent alone, with no subagents; Max 4 is the agent and up to three subagents.
- **The setting is one range with two handles.** In `/effort` and `/team size` the Subagents row
shows `Min 1`, a track from 1 to the cap with a handle for each, and `Max 4`: ←→ move the handle in focus, tab switches to the other, enter
sets both for the session (Min never passes Max; Max goes up to `agents.cap`). `/team size` also
keeps the usual pairs and `<min> <max>` typed.
- **`/context`** shows the window as used tokens of the window and a percentage, one bar split by - **`/context`** shows the window as used tokens of the window and a percentage, one bar split by
category, every category with its tokens and its share of the window, what is free, and how far category, every category with its tokens and its share of the window, what is free, and how far
it is to automatic compaction (`N until auto-compact`, with `/compact` to do it now). it is to automatic compaction (`N until auto-compact`, with `/compact` to do it now).
+3 -3
View File
@@ -894,17 +894,17 @@
] ]
}, },
"agents": { "agents": {
"description": "Subagents (harness team.md, subagents.md): how many agents a session may have at work. Offered where subagents are on and a model could serve one: the session's, when it serves two requests at once, or another model off its one-model server. /team size and /effort change it for the session.", "description": "Subagents (harness team.md, subagents.md): how many agents a session may have at work, counting the session's own agent — Subagents: Min 1, Max 4 by default; 1 is this agent alone. Offered where subagents are on and a model could serve one: the session's, when it serves two requests at once, or another model off its one-model server. /team size and /effort (one range, Min and Max) change it for the session.",
"type": "object", "type": "object",
"properties": { "properties": {
"min": { "min": {
"description": "Subagents, fewest: the fewest agents at work, the lead included (default 1). 2 or more: the lead works through subagents from the start.", "description": "Subagents, Min: the agents at work at least, this agent included (default 1 — 1 is this agent alone). 2 or more: it works through subagents from the start.",
"type": "integer", "type": "integer",
"minimum": 1, "minimum": 1,
"maximum": 9007199254740991 "maximum": 9007199254740991
}, },
"max": { "max": {
"description": "Subagents, most: the most agents at work, the lead included (default 4). 1: one agent, no subagents.", "description": "Subagents, Max: the agents at work at most, this agent included (default 4 — this agent and up to 3 subagents). 1: this agent alone, no subagents.",
"type": "integer", "type": "integer",
"minimum": 1, "minimum": 1,
"maximum": 9007199254740991 "maximum": 9007199254740991
+3 -3
View File
@@ -298,12 +298,12 @@ export const Config = z.strictObject({
effort: z.union([z.enum(EFFORTS), z.literal("off"), z.literal("default")]).optional().describe("The effort level a session starts with, when the model offers it: an effort, off (thinking switched off, on a model with the switch) or default; otherwise the model's own start level."), effort: z.union([z.enum(EFFORTS), z.literal("off"), z.literal("default")]).optional().describe("The effort level a session starts with, when the model offers it: an effort, off (thinking switched off, on a model with the switch) or default; otherwise the model's own start level."),
agents: z agents: z
.strictObject({ .strictObject({
min: z.number().int().min(1).optional().describe("Subagents, fewest: the fewest agents at work, the lead included (default 1). 2 or more: the lead works through subagents from the start."), min: z.number().int().min(1).optional().describe("Subagents, Min: the agents at work at least, this agent included (default 1 — 1 is this agent alone). 2 or more: it works through subagents from the start."),
max: z.number().int().min(1).optional().describe("Subagents, most: the most agents at work, the lead included (default 4). 1: one agent, no subagents."), max: z.number().int().min(1).optional().describe("Subagents, Max: the agents at work at most, this agent included (default 4 — this agent and up to 3 subagents). 1: this agent alone, no subagents."),
cap: z.number().int().min(1).optional().describe("Global config only. The highest max a session may set (default 32)."), cap: z.number().int().min(1).optional().describe("Global config only. The highest max a session may set (default 32)."),
}) })
.optional() .optional()
.describe("Subagents (harness team.md, subagents.md): how many agents a session may have at work. Offered where subagents are on and a model could serve one: the session's, when it serves two requests at once, or another model off its one-model server. /team size and /effort change it for the session."), .describe("Subagents (harness team.md, subagents.md): how many agents a session may have at work, counting the session's own agent — Subagents: Min 1, Max 4 by default; 1 is this agent alone. Offered where subagents are on and a model could serve one: the session's, when it serves two requests at once, or another model off its one-model server. /team size and /effort (one range, Min and Max) change it for the session."),
subagents: z.boolean().optional().describe("false: no subagents — no task tool and no team."), subagents: z.boolean().optional().describe("false: no subagents — no task tool and no team."),
settings_tool: z.enum(["ask", "allow", "off"]).optional().describe("Global config only. The agent's settings tool: ask (default) — each change is approved like any tool call; allow — changes go through without asking (a less strict mode still asks); off — no settings tool."), settings_tool: z.enum(["ask", "allow", "off"]).optional().describe("Global config only. The agent's settings tool: ask (default) — each change is approved like any tool call; allow — changes go through without asking (a less strict mode still asks); off — no settings tool."),
update: Update.optional().describe("Global config only. Updating LLeMbas CLI itself."), update: Update.optional().describe("Global config only. Updating LLeMbas CLI itself."),
+8 -2
View File
@@ -9,6 +9,7 @@ import { existsSync, readFileSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import { parse, parseDocument } from "yaml" import { parse, parseDocument } from "yaml"
import { z } from "zod" import { z } from "zod"
import LOOP_JSON from "../../harness/loop.json"
import { paths } from "./paths.ts" import { paths } from "./paths.ts"
import { asMode, Config, EFFORTS, MODES, PERSONALITIES, UPDATE_CHANNELS } from "./schema.ts" import { asMode, Config, EFFORTS, MODES, PERSONALITIES, UPDATE_CHANNELS } from "./schema.ts"
@@ -29,8 +30,13 @@ export interface SettingDef {
reload?: boolean reload?: boolean
/** What it is when nothing sets it. */ /** What it is when nothing sets it. */
fallback?: string fallback?: string
/** What a person reads beside the key in /settings. */
hint?: string
} }
// The Subagents setting's names and what its numbers count (harness loop.json subagents.setting).
const SUB = LOOP_JSON.subagents.setting
const ALL: Scope[] = ["session", "global", "project"] const ALL: Scope[] = ["session", "global", "project"]
const USER: Scope[] = ["session", "global"] const USER: Scope[] = ["session", "global"]
@@ -38,8 +44,8 @@ export const SETTINGS: SettingDef[] = [
{ key: "model", kind: "model", agent: true, scopes: ALL }, { key: "model", kind: "model", agent: true, scopes: ALL },
{ key: "effort", kind: "effort", agent: true, scopes: ALL, fallback: "the model's start level" }, { key: "effort", kind: "effort", agent: true, scopes: ALL, fallback: "the model's start level" },
// The team's size is the person's: the agent never sets how many agents it may have. // The team's size is the person's: the agent never sets how many agents it may have.
{ key: "agents.min", kind: "number", agent: false, scopes: ALL, fallback: "1" }, { key: "agents.min", kind: "number", agent: false, scopes: ALL, fallback: "1", hint: `${SUB.label}: ${SUB.min} — ${SUB.short}` },
{ key: "agents.max", kind: "number", agent: false, scopes: ALL, fallback: "4" }, { key: "agents.max", kind: "number", agent: false, scopes: ALL, fallback: "4", hint: `${SUB.label}: ${SUB.max} — this agent included; 4 is this agent and up to 3 subagents` },
{ key: "mode", kind: "enum", values: MODES, agent: true, scopes: ALL, fallback: "manual" }, { key: "mode", kind: "enum", values: MODES, agent: true, scopes: ALL, fallback: "manual" },
{ key: "small_model", kind: "model", agent: true, scopes: ALL, fallback: "the session's model" }, { key: "small_model", kind: "model", agent: true, scopes: ALL, fallback: "the session's model" },
{ key: "knowledge", kind: "list", agent: true, scopes: ALL, fallback: "every base" }, { key: "knowledge", kind: "list", agent: true, scopes: ALL, fallback: "every base" },
+1 -1
View File
@@ -123,7 +123,7 @@ export const LOOP: {
subagents: { subagents: {
texts: { not_named: string; same_group: string; one_request: string } texts: { not_named: string; same_group: string; one_request: string }
named: { before: string; after: string } named: { before: string; after: string }
setting: { label: string; min: string; max: string; own: string } setting: { label: string; min: string; max: string; hint: string; short: string; own: string }
} }
/** Teams (spec 3.2.0, harness/team.md): the size setting, names, marks, texts. */ /** Teams (spec 3.2.0, harness/team.md): the size setting, names, marks, texts. */
team: { team: {
+6 -5
View File
@@ -49,7 +49,7 @@ export const SHARED_BUILTINS: { name: string; description: string; hint?: string
{ name: "init", description: "make or update the project's AGENTS.md from what the repository says", hint: "what to focus on" }, { name: "init", description: "make or update the project's AGENTS.md from what the repository says", hint: "what to focus on" },
{ name: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" }, { name: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" },
{ name: "agents", description: "subagents: list them, make one, or hand one a task", hint: "name" }, { name: "agents", description: "subagents: list them, make one, or hand one a task", hint: "name" },
{ name: "team", description: "subagents: the team and the room; how many; stop one", hint: "size <fewest> <most> | stop <name>" }, { name: "team", description: "subagents: the team and the room; how many; stop one", hint: "size <min> <max> | stop <name>" },
] ]
/** The status bar's word for a team with members: its size of the most it may have, and how many /** The status bar's word for a team with members: its size of the most it may have, and how many
@@ -66,10 +66,11 @@ export function teamLabel(team: Team): string {
export function teamText(app: App, arg: string): string { export function teamText(app: App, arg: string): string {
const rt = app.team const rt = app.team
const t = rt.team const t = rt.team
const S = SUBAGENTS.setting
const [verb, ...rest] = arg.trim().split(/\s+/).filter(Boolean) const [verb, ...rest] = arg.trim().split(/\s+/).filter(Boolean)
if (verb === "size") { if (verb === "size") {
const [min, max] = rest.map(Number) const [min, max] = rest.map(Number)
if (min === undefined || max === undefined || Number.isNaN(min) || Number.isNaN(max)) return `/team size <fewest> <most> — now ${t.min} to ${t.max} (at most ${t.cap})` if (min === undefined || max === undefined || Number.isNaN(min) || Number.isNaN(max)) return `/team size <min> <max> — now ${S.label}: ${S.min} ${t.min}, ${S.max} ${t.max} (Max at most ${t.cap}). ${S.hint}`
// Checked whole before anything is set: a refused pair changes nothing (team.md §2). // Checked whole before anything is set: a refused pair changes nothing (team.md §2).
const no = Team.check(min, max, t.cap) const no = Team.check(min, max, t.cap)
if (no) return no if (no) return no
@@ -80,7 +81,7 @@ export function teamText(app: App, arg: string): string {
} catch (e) { } catch (e) {
return (e as Error).message return (e as Error).message
} }
return `Subagents: ${t.min} to ${t.max} agents at work for this session, the lead included; it is told with your next message.` return `${S.label}: ${S.min} ${t.min}, ${S.max} ${t.max} — ${S.short}. Set for this session; the agent is told with your next message.`
} }
if (verb === "stop") { if (verb === "stop") {
const name = rest[0] const name = rest[0]
@@ -93,8 +94,8 @@ export function teamText(app: App, arg: string): string {
? `No subagents in this session: ${app.engine.model.ref} serves one request at a time, and every other model is on its one-model server.` ? `No subagents in this session: ${app.engine.model.ref} serves one request at a time, and every other model is on its one-model server.`
: "No subagents in this session: they are switched off (subagents: false)." : "No subagents in this session: they are switched off (subagents: false)."
const since = Math.max(0, t.room.length - TEAM.read_max) const since = Math.max(0, t.room.length - TEAM.read_max)
const own = t.couldServe().own ? "" : `\n${SUBAGENTS.setting.own}` const own = t.couldServe().own ? "" : `\n${S.own}`
return `${t.read("lead", since)}\n\nSubagents: ${SUBAGENTS.setting.min} ${t.min}, ${SUBAGENTS.setting.max} ${t.max} agents, the lead included (/team size <fewest> <most>); /team stop <name> stops a member.${own}` return `${t.read("lead", since)}\n\n${S.label}: ${S.min} ${t.min}, ${S.max} ${t.max} — ${S.short} (/team size <min> <max>); /team stop <name> stops a member.${own}`
} }
/** `/agents [name]`: the subagents the task tool offers, or one of them — the text both the TUI and /** `/agents [name]`: the subagents the task tool offers, or one of them — the text both the TUI and
+1 -1
View File
@@ -155,7 +155,7 @@ export class Team {
/** Where it is set: a value outside 1 ≤ min ≤ max ≤ cap is refused with the bounds. */ /** Where it is set: a value outside 1 ≤ min ≤ max ≤ cap is refused with the bounds. */
static check(min: number, max: number, cap: number): string | undefined { static check(min: number, max: number, cap: number): string | undefined {
if (!Number.isInteger(min) || !Number.isInteger(max) || min < 1 || min > max || max > cap) return `agents: 1 ≤ min ≤ max ≤ ${cap} (the cap) — whole numbers; min ${min}, max ${max} is outside` if (!Number.isInteger(min) || !Number.isInteger(max) || min < 1 || min > max || max > cap) return `Subagents: 1 ≤ Min ≤ Max ≤ ${cap} (the cap), whole numbers, this agent included; Min ${min}, Max ${max} is outside`
return undefined return undefined
} }
+34 -20
View File
@@ -16,7 +16,7 @@ import { changelogPrompt, initPrompt, reviewPrompt, teamText } from "../session/
import { git } from "../git/run.ts" import { git } from "../git/run.ts"
import { branches, deleteBranch, switchBranch } from "../git/branch.ts" import { branches, deleteBranch, switchBranch } from "../git/branch.ts"
import type { Ctx } from "./context.ts" import type { Ctx } from "./context.ts"
import type { DialogSpec } from "./components/dialog.tsx" import type { DialogSpec, Option } from "./components/dialog.tsx"
import { deleteEverywhere } from "../acp/share.ts" import { deleteEverywhere } from "../acp/share.ts"
import { themeNames } from "./theme.ts" import { themeNames } from "./theme.ts"
import { render } from "../plan/plan.ts" import { render } from "../plan/plan.ts"
@@ -96,26 +96,44 @@ report is all the session's model sees of your work.
const TEAM_ROW = "\u0000team" const TEAM_ROW = "\u0000team"
/** The team's size for this session: a few usual pairs, or any typed as `<min> <max>`. */ /** The Subagents setting as one range from 1 to the cap with two handles, Min and Max (harness
* team.md §2): the numbers count this agent, so 1 is this agent alone. */
function teamRange(c: CommandCtx): Option {
const t = c.app.team.team
const S = SUBAGENTS.setting
return {
label: S.label,
value: TEAM_ROW,
// The session's model cannot serve subagents itself: said where the size is set (subagents.md §4).
description: t.couldServe().own ? S.short : S.own,
range: { from: 1, to: t.cap, lo: t.min, hi: t.max, names: [S.min, S.max], onSubmit: (lo, hi) => applyTeamSize(c, `${lo} ${hi}`) },
}
}
const applyTeamSize = (c: CommandCtx, v: string) => (c.view.info(teamText(c.app, `size ${v}`)), c.refresh())
/** The team's size for this session: the range, a few usual pairs, or any typed as `<min> <max>`. */
function openTeamSize(c: CommandCtx) { function openTeamSize(c: CommandCtx) {
const t = c.app.team.team const t = c.app.team.team
const pairs = [ const pairs = [
[1, 1, "one agent: no subagents"], [1, 1, "this agent alone: no subagents"],
[1, 4, "the agent decides; up to three subagents"], [1, 4, "this agent decides; up to three subagents"],
[2, 4, "subagents from the start"], [2, 4, "subagents from the start"],
[1, 8, "the agent decides; up to seven subagents"], [1, 8, "this agent decides; up to seven subagents"],
[3, 8, "at least two subagents"], [3, 8, "at least two subagents"],
[1, Math.min(16, t.cap), "many subagents, with sub-managers"], [1, Math.min(16, t.cap), "many subagents, with sub-managers"],
] as const ] as const
const S = SUBAGENTS.setting
const seen = new Set<string>() const seen = new Set<string>()
const options = pairs const options = pairs
.filter(([min, max]) => max <= t.cap && !seen.has(`${min} ${max}`) && seen.add(`${min} ${max}`)) .filter(([min, max]) => max <= t.cap && !seen.has(`${min} ${max}`) && seen.add(`${min} ${max}`))
.map(([min, max, what]) => ({ label: `${min} to ${max}`, value: `${min} ${max}`, description: what, current: t.min === min && t.max === max })) .map(([min, max, what]) => ({ label: `${S.min} ${min}, ${S.max} ${max}`, value: `${min} ${max}`, description: what, current: t.min === min && t.max === max }))
const apply = (v: string) => (c.view.info(teamText(c.app, `size ${v}`)), c.refresh()) c.openDialog({
const S = SUBAGENTS.setting title: `${S.label} — ${S.min} ${t.min}, ${S.max} ${t.max} (1 to ${t.cap}, this agent included)`,
// The session's model cannot serve subagents itself: said where the size is set (subagents.md §4). options: [teamRange(c), ...options],
const own = t.couldServe().own ? "" : ` — ${S.own}` onSelect: (v) => applyTeamSize(c, v),
c.openDialog({ title: `${S.label} — now ${S.min} ${t.min}, ${S.max} ${t.max} (agents at work, this one included; at most ${t.cap})${own}`, options, onSelect: apply, custom: { label: "set to <fewest> <most>", onSubmit: apply } }) custom: { label: "set to <min> <max>", onSubmit: (v) => applyTeamSize(c, v) },
})
} }
export const COMMANDS: Command[] = [ export const COMMANDS: Command[] = [
@@ -220,12 +238,8 @@ export const COMMANDS: Command[] = [
} }
// The Subagents setting sits beside the effort (team.md §2, subagents.md §4: shown only where // The Subagents setting sits beside the effort (team.md §2, subagents.md §4: shown only where
// subagents are offered). // subagents are offered).
const team = c.app.team.team const agentsRow = c.app.team.team.offered ? [teamRange(c)] : []
const S = SUBAGENTS.setting const choose = (v: string) => set(v)
const agentsRow = team.offered
? [{ label: `${S.label}: ${S.min} ${team.min}, ${S.max} ${team.max}`, value: TEAM_ROW, description: team.couldServe().own ? "agents at work, this one included" : S.own }]
: []
const choose = (v: string) => (v === TEAM_ROW ? openTeamSize(c) : set(v))
if (control === "none" && (arg || !agentsRow.length)) return c.view.info(`${m.ref} has one level (${levels[0]}): there is nothing to choose`) if (control === "none" && (arg || !agentsRow.length)) return c.view.info(`${m.ref} has one level (${levels[0]}): there is nothing to choose`)
if (arg) return set(arg) if (arg) return set(arg)
if (control === "none") return c.openDialog({ title: `Chat settings — ${m.ref}`, options: agentsRow, onSelect: choose }) if (control === "none") return c.openDialog({ title: `Chat settings — ${m.ref}`, options: agentsRow, onSelect: choose })
@@ -248,7 +262,7 @@ export const COMMANDS: Command[] = [
}, },
{ {
name: "team", name: "team",
args: "[size [<fewest> <most>] | stop [<name>]]", args: "[size [<min> <max>] | stop [<name>]]",
description: "subagents: the team and the room; how many; stop one", description: "subagents: the team and the room; how many; stop one",
run(c, arg) { run(c, arg) {
// The tree and the room as the lead sees them — the person sees everything (team.md §5). // The tree and the room as the lead sees them — the person sees everything (team.md §5).
@@ -1013,7 +1027,7 @@ export const COMMANDS: Command[] = [
? [...(e.def.values ?? [])] ? [...(e.def.values ?? [])]
: [] : []
c.openDialog({ c.openDialog({
title: `${key} — now ${show(e.value) || e.def.fallback || "unset"}${e.source === "default" ? "" : ` (${e.source})`}`, title: `${key} — now ${show(e.value) || e.def.fallback || "unset"}${e.source === "default" ? "" : ` (${e.source})`}${e.def.hint ? ` · ${e.def.hint}` : ""}`,
options: values.map((v) => ({ label: v, value: v, current: v === show(e.value) })), options: values.map((v) => ({ label: v, value: v, current: v === show(e.value) })),
onSelect: (v) => askScope(key, v), onSelect: (v) => askScope(key, v),
custom: e.def.kind === "enum" || e.def.kind === "boolean" || e.def.kind === "effort" ? undefined : { label: "set to", onSubmit: (v) => askScope(key, v) }, custom: e.def.kind === "enum" || e.def.kind === "boolean" || e.def.kind === "effort" ? undefined : { label: "set to", onSubmit: (v) => askScope(key, v) },
@@ -1029,7 +1043,7 @@ export const COMMANDS: Command[] = [
options: S.list().map((e) => ({ options: S.list().map((e) => ({
label: e.def.key, label: e.def.key,
value: e.def.key, value: e.def.key,
description: `${show(e.value) || e.def.fallback || "unset"}${e.source === "default" ? "" : ` · ${e.source}`}${e.def.reload ? " · after restart" : ""}`, description: `${show(e.value) || e.def.fallback || "unset"}${e.source === "default" ? "" : ` · ${e.source}`}${e.def.reload ? " · after restart" : ""}${e.def.hint ? ` · ${e.def.hint}` : ""}`,
})), })),
onSelect: askValue, onSelect: askValue,
}) })
+68 -1
View File
@@ -8,6 +8,31 @@ export interface Option {
value: string value: string
description?: string description?: string
current?: boolean current?: boolean
/** A two-handle range on this row (the Subagents setting, harness team.md §2: one range from 1
* to the cap, Min and Max). While the row is highlighted ←→ move the handle in focus and tab
* moves the focus; Min never passes Max. Enter calls `onSubmit` with both, not `onSelect`. */
range?: Range
}
export interface Range {
/** The bounds of the track. */
from: number
to: number
/** The handles' values. */
lo: number
hi: number
/** The handles' names (Min, Max). */
names: [string, string]
onSubmit: (lo: number, hi: number) => void
}
/** The track as text: `─` outside the range, `━` inside it, `●` for a handle. */
export function rangeTrack(r: Pick<Range, "from" | "to">, lo: number, hi: number, width = 16): { before: string; lo: string; between: string; hi: string; after: string } {
const span = Math.max(1, r.to - r.from)
const at = (v: number) => Math.round(((v - r.from) / span) * (width - 1))
const a = at(lo)
const b = Math.max(at(hi), a + (hi > lo ? 1 : 0))
return { before: "─".repeat(a), lo: "●", between: "━".repeat(Math.max(0, b - a - 1)), hi: b > a ? "●" : "", after: "─".repeat(Math.max(0, width - 1 - b)) }
} }
export interface DialogSpec { export interface DialogSpec {
@@ -45,6 +70,16 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
const hits = q ? p.spec.options.filter((o) => (o.label + " " + (o.description ?? "")).toLowerCase().includes(q)) : p.spec.options const hits = q ? p.spec.options.filter((o) => (o.label + " " + (o.description ?? "")).toLowerCase().includes(q)) : p.spec.options
return p.spec.custom && query().trim() ? [{ label: `${p.spec.custom.label} "${query().trim()}"`, value: CUSTOM }, ...hits] : hits return p.spec.custom && query().trim() ? [{ label: `${p.spec.custom.label} "${query().trim()}"`, value: CUSTOM }, ...hits] : hits
}) })
// A range row's handles, moved while the dialog is open and handed over on enter; esc drops them.
const [ranges, setRanges] = createSignal<Record<string, [number, number]>>({})
const [focus, setFocus] = createSignal<0 | 1>(1)
const valuesOf = (o: Option): [number, number] => ranges()[o.value] ?? [o.range!.lo, o.range!.hi]
const move = (o: Option, by: number) => {
const r = o.range!
const [lo, hi] = valuesOf(o)
const next: [number, number] = focus() === 0 ? [Math.min(hi, Math.max(r.from, lo + by)), hi] : [lo, Math.min(r.to, Math.max(lo, hi + by))]
setRanges((m) => ({ ...m, [o.value]: next }))
}
const start = Math.max(0, p.spec.options.findIndex((o) => o.current)) const start = Math.max(0, p.spec.options.findIndex((o) => o.current))
const [sel, setSel] = createSignal(start) const [sel, setSel] = createSignal(start)
// The spec is live: a list that redraws itself with fewer rows keeps the cursor on a row. // The spec is live: a list that redraws itself with fewer rows keeps the cursor on a row.
@@ -70,6 +105,8 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
run(o.value) run(o.value)
} }
} else if (key.name === "escape") p.close() } else if (key.name === "escape") p.close()
else if (shown()[sel()]?.range && (key.name === "left" || key.name === "right")) move(shown()[sel()]!, key.name === "left" ? -1 : 1)
else if (shown()[sel()]?.range && key.name === "tab") setFocus((f) => (f === 0 ? 1 : 0))
else if (key.name === "up" || (key.ctrl && key.name === "p")) setSel((i) => (i + n - 1) % Math.max(n, 1)) else if (key.name === "up" || (key.ctrl && key.name === "p")) setSel((i) => (i + n - 1) % Math.max(n, 1))
else if (key.name === "down" || (key.ctrl && key.name === "n")) setSel((i) => (i + 1) % Math.max(n, 1)) else if (key.name === "down" || (key.ctrl && key.name === "n")) setSel((i) => (i + 1) % Math.max(n, 1))
else if (key.name === "return") { else if (key.name === "return") {
@@ -79,8 +116,10 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
const onSelect = p.spec.onSelect const onSelect = p.spec.onSelect
const custom = p.spec.custom const custom = p.spec.custom
const typed = query().trim() const typed = query().trim()
const range = o.range && { submit: o.range.onSubmit, values: valuesOf(o) }
p.close() p.close()
if (o.value === CUSTOM) custom?.onSubmit(typed) if (o.value === CUSTOM) custom?.onSubmit(typed)
else if (range) range.submit(...range.values)
else onSelect(o.value) else onSelect(o.value)
} }
} else if (filterable() && key.name === "backspace") { } else if (filterable() && key.name === "backspace") {
@@ -104,6 +143,30 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
<For each={shown().slice(offset(), offset() + visible())}> <For each={shown().slice(offset(), offset() + visible())}>
{(o, i) => { {(o, i) => {
const active = () => i() + offset() === sel() const active = () => i() + offset() === sel()
if (o.range) {
const values = () => valuesOf(o)
const track = () => rangeTrack(o.range!, values()[0], values()[1])
const handle = (n: 0 | 1) => (active() && focus() === n ? c().accent : active() ? c().title : c().text)
return (
<box flexDirection="row" backgroundColor={active() ? c().menuCurrent : undefined}>
<text wrapMode="none" truncate fg={active() ? c().title : c().text} attributes={active() ? TextAttributes.BOLD : 0}>
{" "}
{o.label}
{" "}
<span style={{ fg: handle(0) }}>{`${o.range.names[0]} ${values()[0]}`}</span>
{" "}
<span style={{ fg: c().dim }}>{track().before}</span>
<span style={{ fg: handle(0) }}>{track().lo}</span>
<span style={{ fg: c().accent }}>{track().between}</span>
<span style={{ fg: handle(1) }}>{track().hi}</span>
<span style={{ fg: c().dim }}>{track().after}</span>
{" "}
<span style={{ fg: handle(1) }}>{`${o.range.names[1]} ${values()[1]}`}</span>
<span style={{ fg: c().muted }}>{o.description ? ` ${o.description}` : ""}</span>
</text>
</box>
)
}
return ( return (
<box flexDirection="row" backgroundColor={active() ? c().menuCurrent : undefined}> <box flexDirection="row" backgroundColor={active() ? c().menuCurrent : undefined}>
<text wrapMode="none" truncate fg={active() ? c().title : c().text} attributes={active() ? TextAttributes.BOLD : 0}> <text wrapMode="none" truncate fg={active() ? c().title : c().text} attributes={active() ? TextAttributes.BOLD : 0}>
@@ -118,7 +181,11 @@ export function Dialog(p: { spec: DialogSpec; close: () => void }) {
<Show when={shown().length === 0}> <Show when={shown().length === 0}>
<text fg={c().muted}>nothing matches</text> <text fg={c().muted}>nothing matches</text>
</Show> </Show>
<text fg={c().dim}>{p.spec.hint ?? "↑↓ move · enter choose · esc close"}</text> <text fg={c().dim}>
{shown()[sel()]?.range
? `←→ move ${shown()[sel()]!.range!.names[focus()]} · tab ${shown()[sel()]!.range!.names[focus() === 0 ? 1 : 0]} · enter set · esc close`
: (p.spec.hint ?? "↑↓ move · enter choose · esc close")}
</text>
</box> </box>
</box> </box>
) )
+3 -3
View File
@@ -135,10 +135,10 @@ describe("a team at work (team.md)", () => {
const dir = setup(() => say(`ok ${n++}`)) const dir = setup(() => say(`ok ${n++}`))
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false }) app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
await app.engine.prompt("one") await app.engine.prompt("one")
expect(teamText(app, "size 3 2")).toContain("1 ≤ min ≤ max ≤ 32") expect(teamText(app, "size 3 2")).toContain("1 ≤ Min ≤ Max ≤ 32")
expect(teamText(app, "size 1 40")).toContain("1 ≤ min ≤ max ≤ 32") expect(teamText(app, "size 1 40")).toContain("1 ≤ Min ≤ Max ≤ 32")
expect([app.team.team.min, app.team.team.max]).toEqual([1, 4]) expect([app.team.team.min, app.team.team.max]).toEqual([1, 4])
expect(teamText(app, "size 2 6")).toContain("2 to 6") expect(teamText(app, "size 2 6")).toContain("Min 2, Max 6")
expect(app.settings.get("agents.max").value).toBe(6) expect(app.settings.get("agents.max").value).toBe(6)
await app.engine.prompt("two") await app.engine.prompt("two")
expect(lastUser(fake!.requests[1])).toContain("The team may have 2 to 6 agents, you included.") expect(lastUser(fake!.requests[1])).toContain("The team may have 2 to 6 agents, you included.")
+47
View File
@@ -36,3 +36,50 @@ test("dialog: arrow down + enter picks the second option — with a spec that cl
expect(picked).toEqual(["b"]) expect(picked).toEqual(["b"])
expect(closed).toBe(1) expect(closed).toBe(1)
}) })
test("dialog: a range row is one track with two handles — ←→ move the one in focus, tab the other, enter hands both over", async () => {
const set: [number, number][] = []
const picked: string[] = []
const ctx = { theme: () => DEFAULT_THEME, dims: () => ({ width: 72, height: 16 }) } as never
const [spec, setSpec] = createSignal<DialogSpec | undefined>({
title: "Effort",
options: [
{ label: "high", value: "high", current: true },
{ label: "Subagents", value: "team", description: "1 is this agent alone", range: { from: 1, to: 32, lo: 1, hi: 4, names: ["Min", "Max"], onSubmit: (lo, hi) => set.push([lo, hi]) } },
],
onSelect: (v) => picked.push(v),
})
setup = await testRender(
() => (
<TuiContext.Provider value={ctx}>
<Show when={spec()}>
<Dialog spec={spec()!} close={() => setSpec(undefined)} />
</Show>
</TuiContext.Provider>
),
{ width: 72, height: 16 },
)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Subagents Min 1 ●")
setup.mockInput.pressArrow("down")
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("←→ move Max · tab Min")
setup.mockInput.pressArrow("right")
setup.mockInput.pressArrow("right")
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Max 6")
setup.mockInput.pressTab()
setup.mockInput.pressArrow("right")
setup.mockInput.pressArrow("right")
setup.mockInput.pressArrow("left")
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Min 2")
// Min never passes Max, and Min never goes under 1.
for (let i = 0; i < 10; i++) setup.mockInput.pressArrow("right")
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Min 6")
setup.mockInput.pressEnter()
await setup.renderOnce()
expect(set).toEqual([[6, 6]])
expect(picked).toEqual([])
})