Andrey Epifancev 069c5643b1 feat(plugins): мод /feature — панель фичи, перенос в обвязку (#7)
Мод Claude Code жил во временной папке сессии obsidian-voice-note и в
новых сессиях не загружался. Перенесён как есть в plugins/feature/,
плагин переименован plan-progress → feature (команда /feature, запасные
/progress и /feature-pane). Поведение не менялось.

- README мода: что показывает, контракт с форматами обвязки (ledger,
  ## Findings, Decisions в design.md, планы, .bounded), разработка.
- Сгенерированные движком типы (.claude-plugin/types/) — в .gitignore.
- Строки о моде в CLAUDE.md и README обвязки; install.sh его пока не ставит.

Проверено: claude plugin test plugins/feature — 22/22, claude plugin
validate — без ошибок, tsc — чисто, bash tests/run.sh — 157/157.

Refs #7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:37:42 +04:00

894 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Register, EngineInterface, Timer } from 'claude-code'
import {
bar,
buildProgress,
changedPaths,
defaultTab,
issueOfBranch,
joinPath,
manualTasks,
parseDecisions,
parseFindings,
parseLedger,
parsePlan,
parseWorktrees,
stageOf,
statusNote,
TABS,
worktreeOf,
wrapFocus,
} from './parse.ts'
import type { Decision, Doc, Findings, Ledger, Plan, Progress, Stage, Tab, TaskView, Worktree } from './parse.ts'
const PANE = 'feature'
// /feature may be taken by a built-in or another plugin; fall back to the next free name
const NAMES = ['feature', 'progress', 'feature-pane']
const PLANS_DIR = 'docs/superpowers/plans'
const SDD_DIR = '.superpowers/sdd'
// SDD runs in a background subagent, so its edits do not always pass through this session's tool calls
const POLL_MS = 15_000
type Ruling = Decision & { marker?: string; path: string }
/** One thing to show: a plan in a worktree, or a worktree with no plan (a bounded branch). */
type Target = {
key: string
worktree: Worktree
issue?: number
changes: string[]
bounded: boolean
commits: string[]
pushed: boolean
planPath?: string
plan?: Plan
ledger?: Ledger
ledgerPath?: string
progress?: Progress
findings?: Findings
rulings: Ruling[]
docs: Doc[]
stage: Stage
activityMs: number
}
// Module state: rebuilt from git and disk on every open, refresh and edit, so a reload loses nothing
let commandName = ''
let targets: Target[] = []
let selected: string | undefined
let hideDone = false
// Раскрытые задачи по каждому плану (ключ Target); плана нет в карте — раскрыта следующая задача
const expanded = new Map<string, Set<number>>()
// Выбранная вкладка по каждому Target; нет в карте — по этапу
const tabs = new Map<string, Tab>()
// Раскрытые рулинги (номер решения) и находки (маркер) по каждому Target
const openRulings = new Map<string, Set<number>>()
const openFindings = new Map<string, Set<string>>()
let isOpen = false
let loaded = false
let error = ''
let loading: Promise<void> | undefined
let poll: Timer | undefined
// Строки списка в порядке отрисовки и последняя строка с фокусом: стрелки ходят только по ним
let rows: string[] = []
let lastRow: string | undefined
// Команда просмотрщика: glow, если он есть, иначе less; ищется один раз
let viewerCmd: string | undefined
async function git($: EngineInterface, cwd: string, args: string[]): Promise<string | undefined> {
try {
const r = await $.process.run(['git', ...args], { cwd, timeoutMs: 10_000 })
return r.exitCode === 0 ? r.stdout : undefined
} catch {
return undefined
}
}
async function readText($: EngineInterface, path: string): Promise<string | undefined> {
try {
return await $.fs.read(path)
} catch {
// Missing, or mid-write: skip it this round
return undefined
}
}
async function mtime($: EngineInterface, path: string): Promise<number> {
try {
return (await $.fs.stat(path)).mtimeMs
} catch {
return 0
}
}
async function dirs($: EngineInterface, path: string): Promise<string[]> {
try {
return (await $.fs.list(path)).filter(e => e.kind === 'dir').map(e => e.name)
} catch {
return []
}
}
async function exists($: EngineInterface, path: string): Promise<boolean> {
try {
return await $.fs.exists(path)
} catch {
return false
}
}
async function viewer($: EngineInterface): Promise<string> {
if (viewerCmd) return viewerCmd
let glow: string | undefined
try {
const r = await $.process.run(['/usr/bin/which', 'glow'], { timeoutMs: 5_000 })
if (r.exitCode === 0) glow = r.stdout.trim() || undefined
} catch {
// which itself missing: try the usual places
}
for (const p of ['/opt/homebrew/bin/glow', '/usr/local/bin/glow']) {
if (!glow && (await exists($, p))) glow = p
}
viewerCmd = glow ? `${glow} -t` : '/usr/bin/less'
return viewerCmd
}
/**
* Открыть документ в терминальном просмотрщике рядом с сессией. Свой терминал у мода
* не интерактивный (вывод уходит в пайп), поэтому — соседняя панель iTerm или окно Terminal.
*/
async function openDoc($: EngineInterface, path: string): Promise<void> {
const term = await $.env.get('TERM_PROGRAM')
const cmd = await viewer($)
const script =
term === 'iTerm.app'
? 'tell application "iTerm2" to tell current session of current window to split vertically with default profile command ((item 1 of argv) & " " & quoted form of (item 2 of argv))'
: term === 'Apple_Terminal'
? 'tell application "Terminal"\nactivate\ndo script ((item 1 of argv) & " " & quoted form of (item 2 of argv))\nend tell'
: undefined
try {
const r = script
? await $.process.run(['osascript', '-e', 'on run argv', '-e', script, '-e', 'end run', cmd, path], {
timeoutMs: 10_000,
})
: await $.process.run(['open', path], { timeoutMs: 10_000 })
if (r.exitCode !== 0) $.ui.toast(`Не открылось: ${r.stderr.trim() || 'код ' + r.exitCode}`)
} catch (err) {
$.ui.toast(`Не открылось: ${err instanceof Error ? err.message : String(err)}`)
}
}
const CHANGE_DOCS: [file: string, label: string][] = [
['proposal', 'Proposal — зачем фича и что меняется'],
['design', 'Design — устройство и решения без обсуждения'],
]
/** Документы change: proposal, design, спеки capability. README — только указатель, его нет. */
async function changeDocs($: EngineInterface, wt: string, name: string, several: boolean): Promise<Doc[]> {
const dir = joinPath(wt, `openspec/changes/${name}`)
// Несколько change в одной ветке — подписи различает имя change
const of = several ? ` (${name})` : ''
const docs: Doc[] = []
for (const [file, label] of CHANGE_DOCS) {
if (await exists($, `${dir}/${file}.md`)) docs.push({ label: label + of, path: `${dir}/${file}.md` })
}
for (const cap of await dirs($, `${dir}/specs`)) {
if (await exists($, `${dir}/specs/${cap}/spec.md`)) {
docs.push({ label: `Спека ${cap} — требования и сценарии${of}`, path: `${dir}/specs/${cap}/spec.md` })
}
}
return docs
}
async function loadWorktree($: EngineInterface, wt: Worktree, base: string | undefined): Promise<Target[]> {
const onBase = !wt.branch || wt.branch === base
const changes = (await dirs($, joinPath(wt.path, 'openspec/changes'))).filter(n => n !== 'archive')
const bounded = await exists($, joinPath(wt.path, '.bounded'))
const log = !onBase && base ? await git($, wt.path, ['log', '--format=%h %s', '-n', '8', `${base}..HEAD`]) : ''
const commits = (log ?? '').split(/\r?\n/).filter(Boolean)
// На сервере и ничего не осталось непушнутым
const ahead = onBase ? undefined : await git($, wt.path, ['rev-list', '--count', '@{u}..HEAD'])
const pushed = ahead !== undefined && ahead.trim() === '0'
const changeDocsList: Doc[] = []
const decisions: { d: Decision; path: string }[] = []
for (const name of changes) {
changeDocsList.push(...(await changeDocs($, wt.path, name, changes.length > 1)))
const designPath = joinPath(wt.path, `openspec/changes/${name}/design.md`)
const design = await readText($, designPath)
if (design) decisions.push(...parseDecisions(design).map(d => ({ d, path: designPath })))
}
// Plans SDD runs on, else the plans this branch adds or changes
const runs: { planRel: string; ledger?: Ledger; ledgerText?: string; ledgerPath?: string; ledgerMs: number }[] = []
for (const name of await dirs($, joinPath(wt.path, SDD_DIR))) {
const ws = joinPath(wt.path, `${SDD_DIR}/${name}`)
const text = await readText($, ws + '/progress.md')
const ledger = text === undefined ? undefined : parseLedger(text)
const marker = (await readText($, ws + '/plan-path'))?.trim()
const planRel = marker || ledger?.plan || `${PLANS_DIR}/${name}.md`
runs.push({
planRel,
ledger,
ledgerText: text,
ledgerPath: text === undefined ? undefined : ws + '/progress.md',
ledgerMs: await mtime($, ws + '/progress.md'),
})
}
if (runs.length === 0) {
const diff = !onBase && base ? await git($, wt.path, ['diff', '--name-only', `${base}...HEAD`, '--', PLANS_DIR]) : ''
const status = await git($, wt.path, ['status', '--porcelain', '--untracked-files=all', '--', PLANS_DIR])
for (const rel of changedPaths(diff ?? '', status ?? '')) {
if (rel.endsWith('.md')) runs.push({ planRel: rel, ledgerMs: 0 })
}
}
const common = { worktree: wt, changes, bounded, commits, pushed }
const rulingsFor = (ledger?: Ledger): Ruling[] =>
decisions
// Решения, выбранные человеком, смотреть не нужно
.filter(x => !x.d.human)
.map(x => ({ ...x.d, marker: ledger?.rulingOf.get(x.d.n), path: x.path }))
if (runs.length === 0) {
// The main checkout with nothing in flight is not worth a row
if (onBase && changes.length === 0 && !bounded) return []
const stage = stageOf({ change: changes.length > 0, bounded, manual: [], pushed, commits: commits.length })
return [
{
key: wt.path,
issue: issueOfBranch(wt.branch),
...common,
rulings: rulingsFor(),
docs: changeDocsList,
stage,
activityMs: 0,
},
]
}
const out: Target[] = []
for (const run of runs) {
const planPath = joinPath(wt.path, run.planRel)
const text = await readText($, planPath)
const plan = text === undefined ? undefined : parsePlan(run.planRel, text)
const progress = plan ? buildProgress(plan, run.ledger) : undefined
const docs = [...changeDocsList]
if (plan) docs.push({ label: `План — ${plan.title || run.planRel}`, path: planPath })
if (run.ledgerPath) docs.push({ label: 'SDD ledger — ход исполнения и находки', path: run.ledgerPath })
out.push({
key: wt.path + '::' + run.planRel,
issue: plan?.issue ?? issueOfBranch(wt.branch),
...common,
planPath: run.planRel,
plan,
ledger: run.ledger,
ledgerPath: run.ledgerPath,
progress,
findings: run.ledgerText === undefined ? undefined : parseFindings(run.ledgerText),
rulings: rulingsFor(run.ledger),
docs,
stage: stageOf({
change: changes.length > 0,
bounded,
planPath: run.planRel,
progress,
ledger: run.ledger,
manual: manualTasks(plan, run.ledger),
pushed,
commits: commits.length,
}),
activityMs: Math.max(run.ledgerMs, await mtime($, planPath)),
})
}
return out
}
/** Default: the session's own worktree, its most active plan; else the most active plan anywhere. */
function defaultKey(worktrees: Worktree[], root: string): string | undefined {
const own = worktreeOf(worktrees, root)
const byActivity = [...targets].sort((a, b) => b.activityMs - a.activityMs)
const mine = byActivity.filter(t => t.worktree.path === own?.path)
return (mine[0] ?? byActivity.find(t => t.progress) ?? byActivity[0])?.key
}
async function load($: EngineInterface): Promise<void> {
const root = await $.session.root()
const listing = await git($, root, ['worktree', 'list', '--porcelain'])
const worktrees = listing ? parseWorktrees(listing) : [{ path: root }]
const base =
(await git($, root, ['rev-parse', '--verify', '--quiet', 'refs/heads/main'])) !== undefined
? 'main'
: (await git($, root, ['rev-parse', '--verify', '--quiet', 'refs/heads/master'])) !== undefined
? 'master'
: undefined
targets = (await Promise.all(worktrees.map(w => loadWorktree($, w, base)))).flat()
loaded = true
error = targets.length === 0 ? 'Нет веток в работе: ни планов, ни SDD-ledger, ни OpenSpec change' : ''
if (!selected || !targets.some(t => t.key === selected)) selected = defaultKey(worktrees, root)
}
async function refresh($: EngineInterface): Promise<void> {
if (!loading) {
loading = load($).finally(() => {
loading = undefined
})
}
await loading
$.ui.invalidate('ui.render')
}
type RowStyle = { dimColor?: boolean; bold?: boolean; color?: 'suggestion' | 'success' | 'warning' }
function expandedFor(key: string, p: Progress): Set<number> {
let set = expanded.get(key)
if (!set) {
set = new Set(p.next ? [p.next.n] : [])
expanded.set(key, set)
}
return set
}
/** Выбор человека запоминается по каждой ветке; иначе — вкладка этапа. */
function tabFor(t: Target): Tab {
return (
tabs.get(t.key) ??
defaultTab({
progress: t.progress,
ledger: t.ledger,
docs: t.docs.length,
rulings: t.rulings.length,
boundedNoPlan: t.bounded && !t.planPath,
pushed: t.pushed,
})
)
}
function startPolling($: EngineInterface): void {
if (poll) return
poll = $.clock.every(POLL_MS, () => {
if (isOpen) void refresh($)
})
}
function stopPolling(): void {
poll?.cancel()
poll = undefined
}
function touchesProgress(path: unknown): boolean {
return (
typeof path === 'string' &&
/(\.superpowers\/sdd\/|docs\/superpowers\/plans\/|openspec\/changes\/|\/\.bounded$)/.test(path)
)
}
function branchName(wt: Worktree): string {
return wt.branch ?? (wt.path.split('/').pop() || wt.path) + ' (detached)'
}
function targetLabel(t: Target, plansInWorktree: number): string {
const plan = plansInWorktree > 1 && t.planPath ? ' ' + (t.planPath.split('/').pop() ?? '') : ''
const state = t.progress
? `${t.progress.done}/${t.progress.total}`
: t.planPath
? 'план не найден'
: t.bounded
? 'bounded'
: 'без плана'
return `${branchName(t.worktree)}${plan} ${state}`
}
function pick(wanted: string): Target | undefined {
const w = wanted.replace(/^#/, '')
return (
targets.find(t => t.worktree.branch === w) ??
targets.find(t => String(t.issue) === w) ??
targets.find(t => (t.worktree.branch ?? '').includes(w) || (t.planPath ?? '').includes(w))
)
}
function setOf<T>(map: Map<string, Set<T>>, key: string): Set<T> {
let set = map.get(key)
if (!set) {
set = new Set<T>()
map.set(key, set)
}
return set
}
export const register: Register = on => {
on('session.start', async ($, e, next) => {
for (const name of NAMES) {
try {
const { command } = await $.command.register({
name,
description: 'Фича по веткам: этап и чей ход, задачи плана, рулинги, отложенное, документы',
argumentHint: '[ветка | issue | план]',
immediate: true,
})
commandName = command
break
} catch {
// Name taken, try the next one
}
}
return next(e)
})
on('command.run', async ($, e, next) => {
if (!commandName || e.command !== commandName) return next(e)
const wanted = (e.args ?? '').trim()
await load($)
if (wanted) selected = pick(wanted)?.key ?? selected
isOpen = true
startPolling($)
await $.ui.open({ id: PANE, title: 'Фича', focus: true, closeOnEscape: true })
return {}
})
on('ui.close', async ($, e, next) => {
if (e.id === PANE) {
isOpen = false
stopPolling()
}
return next(e)
})
// Стрелки и Tab ходят по кругу только по строкам списка; кнопки и ветки — по своим клавишам
on('ui.focus', { requestId: PANE }, async ($, e, next) => {
const el = e.element
if (el && rows.includes(el)) {
lastRow = el
return next(e)
}
const target = e.origin.kind === 'person' ? wrapFocus(rows, lastRow) : undefined
if (!target) return next(e)
lastRow = target
return next({ ...e, element: target })
})
// Live refresh while plans, ledgers and changes are edited in this session
on('tool.call', async ($, e, next) => {
const result = await next(e)
if (!isOpen) return result
const touched =
touchesProgress((e as { file_path?: unknown }).file_path) ||
e.tool === 'Agent' ||
(e.tool === 'Bash' &&
/\bgit\b|openspec|superpowers|\.bounded/.test(String((e as { command?: unknown }).command ?? '')))
if (touched) await refresh($)
return result
})
// And once more at the end of every turn, to catch anything else
on('turn.complete', async ($, e, next) => {
const result = await next(e)
if (isOpen) await refresh($)
return result
})
on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
// Only an open pane is drawn: after a reload this is how the module learns it is open
isOpen = true
startPolling($)
if (!loaded) void refresh($)
const els = $.ui.resolve(e)
const { Box, Text, Button } = els
const columns = Math.max(30, e.props.bodyColumns ?? 60)
const refreshButton = (
<Button key="refresh" label="обновить" hotkey="r" plain dimColor onPress={() => void refresh($)} />
)
if (!loaded || targets.length === 0) {
rows = []
return (
<Box flexDirection="column">
<Text dimColor>{loaded ? error : 'Загрузка…'}</Text>
{refreshButton}
</Box>
)
}
const t = targets.find(x => x.key === selected) ?? (targets[0] as Target)
const redraw = () => $.ui.invalidate('ui.render')
const choose = (key: string) => {
selected = key
redraw()
}
const plansIn = (wt: Worktree) => targets.filter(x => x.worktree.path === wt.path).length
// 9 / 0 — по кругу по всем веткам; цифры работают в любой раскладке
const step = (by: number) => {
const i = targets.findIndex(x => x.key === t.key)
const next = targets[(i + by + targets.length) % targets.length]
if (next) choose(next.key)
}
const branchButtons =
targets.length > 1
? [
// Порядок как на клавиатуре и в карусели: 9 — влево, 0 — вправо
<Button key="prev-branch" label="‹ ветка" hotkey="9" plain dimColor onPress={() => step(-1)} />,
<Button key="next-branch" label="ветка ›" hotkey="0" plain dimColor onPress={() => step(1)} />,
]
: []
// Ветки — карусель: 9 / 0 внизу листают по кругу. Не Select: он забирает стрелки у списка
const index = targets.findIndex(x => x.key === t.key)
const picker =
targets.length > 1 ? (
<Box flexDirection="row" columnGap={1}>
<Text dimColor>‹</Text>
<Text bold wrap="truncate-end">
{targetLabel(t, plansIn(t.worktree))}
</Text>
<Text dimColor>›</Text>
</Box>
) : (
<Text bold wrap="truncate-end">
{branchName(t.worktree)}
</Text>
)
const meta = [
t.issue !== undefined ? '#' + t.issue : undefined,
t.changes.length ? 'openspec: ' + t.changes.join(', ') : undefined,
t.bounded ? 'bounded' : undefined,
t.progress
? t.progress.source === 'ledger'
? 'по SDD-ledger'
: t.progress.anyTicked
? 'по шагам плана'
: 'SDD не запущен'
: undefined,
].filter(Boolean)
const yours = t.stage.who === 'you'
// Шапка по центру панели
const head = (
<Box flexDirection="column" alignItems="center">
{picker}
{/* Точки под каруселью: по одной на ветку, текущая — закрашена */}
{targets.length > 1 && (
<Box flexDirection="row" columnGap={1}>
{targets.map((x, i) => (
<Text key={'dot-' + i} dimColor={i !== index}>
{i === index ? '●' : '○'}
</Text>
))}
</Box>
)}
{meta.length > 0 && (
<Text dimColor wrap="truncate-end">
{meta.join(' · ')}
</Text>
)}
<Box flexDirection="row" columnGap={2}>
<Text bold wrap="truncate-end">{`Этап: ${t.stage.label}`}</Text>
<Text color={yours ? 'warning' : undefined} dimColor={!yours} bold={yours}>
{yours ? 'ход за тобой' : 'работает агент'}
</Text>
</Box>
</Box>
)
// Висячий отступ: префикс — колонка постоянной ширины, текст переносится в своей колонке
const hang = (key: string, prefix: string, body: JSX.Element, prefixProps: RowStyle = {}) => (
<Box key={key} flexDirection="row">
<Box flexShrink={0}>
<Text {...prefixProps}>{prefix}</Text>
</Box>
<Box flexGrow={1} flexShrink={1}>
{body}
</Box>
</Box>
)
const tab = tabFor(t)
const setTab = (next: Tab) => {
tabs.set(t.key, next)
redraw()
}
const focusRows: string[] = []
const body: JSX.Element[] = []
const empty = (text: string) => body.push(<Text key="empty" dimColor>{text}</Text>)
const p = t.progress
const f = t.findings
const deferred = f?.items.filter(i => i.status === 'deferred') ?? []
const openItems = f?.items.filter(i => i.status === 'open') ?? []
// ── 1. Документы: Enter открывает файл в просмотрщике рядом с сессией
if (tab === 'docs') {
if (!t.docs.length) empty('Документов нет: ни OpenSpec change, ни плана')
// Маркированный список; перенос длинной подписи — под текст, не под маркер
t.docs.forEach((d, i) => {
focusRows.push('doc-' + d.path)
body.push(
hang(
'd' + d.path,
'• ',
<Button key={'doc-' + d.path} plain autoFocus={i === 0 || undefined} onPress={() => void openDoc($, d.path)}>
<Text wrap="wrap">{d.label}</Text>
</Button>,
{ dimColor: true },
),
)
})
}
// ── 2. Задачи
if (tab === 'tasks' && p) {
const pct = p.total ? Math.round((p.done / p.total) * 100) : 0
const wide = Math.max(4, Math.min(30, columns - 24))
// Счётчики шагов — только если план кто-то отмечает: SDD шаги не отмечает
const bySteps = p.source === 'steps' && p.anyTicked
const open = expandedFor(t.key, p)
const toggle = (n: number) => {
if (open.has(n)) open.delete(n)
else open.add(n)
redraw()
}
body.push(
<Text key="plan" bold wrap="truncate-end">
{t.plan?.title || t.planPath}
</Text>,
<Box key="bar" flexDirection="row" columnGap={1}>
<Text color={pct === 100 ? 'success' : 'suggestion'}>{bar(p.done, p.total, wide)}</Text>
<Text bold>{`${p.done}/${p.total}`}</Text>
<Text dimColor>{`задач · ${pct}%`}</Text>
</Box>,
<Text key="next" wrap="truncate-end" color={p.next ? 'suggestion' : 'success'}>
{p.next ? `Дальше: Task ${p.next.n} — ${p.next.title}` : 'Все задачи выполнены'}
</Text>,
<Text key="gap"> </Text>,
)
const shown = hideDone ? p.tasks.filter(x => x.status !== 'done') : p.tasks
const hidden = p.tasks.length - shown.length
if (hidden > 0) body.push(<Text key="hidden" dimColor>{`✓ скрыто готовых: ${hidden}`}</Text>)
shown.forEach((task: TaskView, index) => {
const isNext = task === p.next
const isOpenTask = open.has(task.n)
// Статус: ✓ готово, ● в работе или следующая, ○ не начата. Раскрытие — отдельно: + / −
const mark = task.status === 'done' ? '✓' : isNext || task.status === 'active' ? '●' : '○'
const steps = bySteps ? (task.stepsTotal ? `${task.stepsDone}/${task.stepsTotal}` : '').padStart(5) + ' ' : ''
const style: RowStyle = {
dimColor: task.status === 'done',
bold: isNext,
color: isNext ? 'suggestion' : task.status === 'done' ? 'success' : undefined,
}
const prefix = `${task.steps.length ? (isOpenTask ? '−' : '+') : ' '} ${mark} ${steps}${String(task.n).padStart(2)}. `
focusRows.push('task-' + task.n)
body.push(
hang(
't' + task.n,
prefix,
<Button
key={'task-' + task.n}
plain
autoFocus={isNext || (!p.next && index === 0) || undefined}
onPress={() => toggle(task.n)}
>
<Text {...style} wrap="wrap">
{task.title}
</Text>
</Button>,
style,
),
)
const indent = ' '.repeat(prefix.length)
if (task.note) {
body.push(hang('n' + task.n, indent, <Text dimColor wrap="wrap">{task.note}</Text>, { dimColor: true }))
}
if (!isOpenTask) return
task.steps.forEach((s, i) => {
// SDD отмечает готовность задачи в ledger, не шаги: у готовой задачи готовы и шаги
const done = s.done || (p.source === 'ledger' && task.status === 'done')
focusRows.push(`step-${task.n}-${i}`)
body.push(
hang(
`s${task.n}-${i}`,
indent + ' '.repeat(s.depth) + (done ? '✓ ' : '· '),
// Enter на шаге сворачивает задачу и возвращает на неё фокус
<Button
key={`step-${task.n}-${i}`}
plain
onPress={() => {
open.delete(task.n)
redraw()
void $.ui.focus({ requestId: PANE, key: 'task-' + task.n }).catch(() => {})
}}
>
<Text dimColor={done} wrap="wrap">
{s.text}
</Text>
</Button>,
{ dimColor: done },
),
)
})
})
}
// Без плана на вкладке задач — что уже в ветке
if (tab === 'tasks' && !p) {
if (t.planPath) body.push(<Text key="noplan" color="warning">{'План не найден: ' + t.planPath}</Text>)
else empty(t.bounded ? 'Bounded-задача, плана нет' : 'Плана нет')
body.push(
<Text key="commits-h" dimColor>
{t.commits.length
? `Коммиты в ветке (${t.commits.length}${t.commits.length === 8 ? '+' : ''}):`
: 'Коммитов поверх main нет'}
</Text>,
)
t.commits.forEach((c, i) =>
body.push(
<Text key={'c' + i} wrap="truncate-end">
{' ' + c}
</Text>,
),
)
}
// ── 3. Рулинги: решения без обсуждения из design.md, кроме выбранных человеком
if (tab === 'rulings') {
if (!t.rulings.length) empty('Решений без обсуждения нет')
const open = setOf(openRulings, t.key)
t.rulings.forEach((r, i) => {
const isOpenRuling = open.has(r.n)
const more = Boolean(r.why || r.cost)
const prefix = `${more ? (isOpenRuling ? '−' : '+') : ' '} ${(r.marker ?? '').padEnd(4)} #${String(r.n).padEnd(3)}`
focusRows.push('rul-' + r.n)
body.push(
hang(
'r' + r.n,
prefix,
// Enter раскрывает обоснование и цену ошибки прямо в списке
<Button
key={'rul-' + r.n}
plain
autoFocus={i === 0 || undefined}
onPress={() => {
if (isOpenRuling) open.delete(r.n)
else if (more) open.add(r.n)
redraw()
}}
>
<Text wrap="wrap">{r.stage ? `${r.text} (${r.stage})` : r.text}</Text>
</Button>,
{ color: r.marker ? 'suggestion' : undefined, dimColor: !r.marker },
),
)
if (!isOpenRuling) return
const indent = ' '.repeat(prefix.length)
if (r.why) body.push(hang('rw' + r.n, indent, <Text dimColor wrap="wrap">{'Почему: ' + r.why}</Text>))
if (r.cost) body.push(hang('rc' + r.n, indent, <Text dimColor wrap="wrap">{'Цена ошибки: ' + r.cost}</Text>))
})
}
// ── 4. Отложено: открытые находки первыми, затем deferred
if (tab === 'deferred') {
if (!f) empty('SDD-ledger нет: находок пока нет')
else if (!deferred.length && !openItems.length) empty('Отложенного нет')
else
body.push(
<Text key="hint" dimColor>
{f.final ? 'по таблице ## Findings в ledger' : 'предварительно, по строкам ledger: таблицы ## Findings ещё нет'}
</Text>,
)
const open = setOf(openFindings, t.key)
;[...openItems, ...deferred].forEach((item, i) => {
const isOpenItem = item.status === 'open'
// Пояснение к статусу — то, что сверх самого слова: `deferred (решит Task 7, шаг 3)`
const note = statusNote(item.detail)
const isOpenNote = open.has(item.marker)
const prefix = `${note ? (isOpenNote ? '−' : '+') : ' '} ${isOpenItem ? '!' : '·'} ${item.marker.padEnd(7)}`
focusRows.push('find-' + item.marker)
body.push(
hang(
'f' + item.marker,
prefix,
// Enter раскрывает пояснение к статусу, если оно есть
<Button
key={'find-' + item.marker}
plain
autoFocus={i === 0 || undefined}
onPress={() => {
if (isOpenNote) open.delete(item.marker)
else if (note) open.add(item.marker)
redraw()
}}
>
<Text wrap="wrap">{item.text || item.detail || '—'}</Text>
</Button>,
{ color: isOpenItem ? 'warning' : undefined, bold: isOpenItem },
),
)
if (isOpenNote) {
body.push(hang('fn' + item.marker, ' '.repeat(prefix.length), <Text dimColor wrap="wrap">{note}</Text>))
}
})
}
rows = focusRows
// Полоса вкладок: цифры 1–4 работают в любой раскладке; в подписи — счётчик вкладки
const counts: Record<Tab, string> = {
docs: String(t.docs.length),
tasks: p ? `${p.done}/${p.total}` : t.commits.length ? `${t.commits.length} комм.` : '',
rulings: String(t.rulings.length),
deferred: String(deferred.length) + (openItems.length ? ` !${openItems.length}` : ''),
}
const titles: Record<Tab, string> = { docs: 'Документы', tasks: 'Задачи', rulings: 'Рулинги', deferred: 'Отложено' }
const tabBar = (
// Вкладки растянуты по ширине панели
<Box key="tabs" width="100%" flexDirection="row" flexWrap="wrap" justifyContent="space-evenly" columnGap={2} marginTop={1}>
{TABS.map((id, i) => (
<Button
key={'tab-' + id}
label={`${tab === id ? '●' : '○'} ${titles[id]} ${counts[id]}`.trimEnd()}
hotkey={String(i + 1)}
plain
dimColor={tab !== id}
onPress={() => setTab(id)}
/>
))}
</Box>
)
const extra: JSX.Element[] = []
if (tab === 'tasks' && p) {
const open = expandedFor(t.key, p)
extra.push(
<Button
key="expand-all"
label={open.size === p.tasks.length ? 'свернуть' : 'раскрыть'}
hotkey="e"
plain
dimColor
onPress={() => {
if (open.size === p.tasks.length) open.clear()
else for (const task of p.tasks) open.add(task.n)
redraw()
}}
/>,
<Button
key="toggle-done"
label={hideDone ? 'показать ✓' : 'скрыть ✓'}
hotkey="h"
plain
dimColor
onPress={() => {
hideDone = !hideDone
redraw()
}}
/>,
)
}
return (
<Box flexDirection="column">
{head}
{tabBar}
<Text> </Text>
{/* Содержимое вкладки — с отступом от краёв */}
<Box flexDirection="column" paddingX={2}>
{body}
</Box>
<Text> </Text>
{/* Все клавиши — одной приглушённой строкой внизу */}
<Box key="keys" flexDirection="row" flexWrap="wrap" columnGap={2}>
<Text dimColor>{tab === 'docs' ? '↑↓ Enter: открыть' : '↑↓ Enter: раскрыть'}</Text>
{branchButtons}
{extra}
{refreshButton}
</Box>
</Box>
)
})
}