Мод 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>
894 lines
33 KiB
TypeScript
894 lines
33 KiB
TypeScript
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>
|
||
)
|
||
})
|
||
}
|