Subagents: Min and Max, one range with two handles, counting this agent (harness spec 3.3.0)
ci / check (push) Waiting to run
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:
11 files changed
+183
-42
No files matched your search
+9
-3
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."),
|
||||||
|
|||||||
@@ -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
@@ -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: {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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.")
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
})
|
||||||
Reference in new issue
Block a user