# AGENTS.md — working conventions for dsh-tui-pi For agents doing work in this repository. End-user docs live in [README.md](README.md); the full design in [ARCHITECTURE.md](ARCHITECTURE.md); shared vocabulary in [CONTEXT.md](CONTEXT.md); decision records under [docs/adr/](docs/adr/). ## Architecture at a glance ``` index.ts cordis plugin entry (apply + effect) — all wiring: commands, footer, git, clock, bridge, theme hot-swap sink, shutdown tui.ts UI shell: alt-screen, transcript ScrollView, dock, editor rebuild on theme swap (themeRef mutable binding) + transient footer notice slot (showNotice: stacked muted lines above the footer, each auto-dismisses after 8s, capped at 8; fed by the shared notice bridge) messages.ts TranscriptRenderer: session events → components; ReplayOp buffer + setTheme rebuild; chat-clean transcript (think/ tool/todo render in the live widgets, never here) activity.ts ThinkPanel/ToolPanel: the fixed think/tool status panels pinned above the chat input (one of each per run, refreshed in place, hidden while empty; '1' = one row with identifier + elapsed + last line, '5'/'7'/'10'/'all' = boxed heights; self-drawing, resize/theme-safe) + the panel helpers (panelBodyText/clipRow/toolSubject/…) session.ts DshSessionBridge: lazy create, followup, resume/replay, cancel, O(1) stats, persistDefaultModel, subagent tracker (tool-workflow + child events → AgentView rows → onLive; child chunks fold a bounded live content tail) dsh-events.ts local types/guards for tool-workflow/subagent/llm-retry events (declaring packages not installed) + AgentView live-widgets.ts LiveWidgets: the pinned surfaces around the chat input — Todos panel + ThinkPanel + ToolPanel above (applyEvent phase machine), ` ● ` last-request line + compact running-agent lines below (content-line tail, right- truncated; renderTodos/renderAgents/tickLive/setTheme; show-when-content, clear-when-done) append-system.ts APPEND_SYSTEM.md support (pi convention, dsh side ~/.dsh/APPEND_SYSTEM.md — a RUNTIME user file): install seeds a fresh file from templates/APPEND_SYSTEM.md (the English orchestrator template; content in the file, not code); the dsh-tui-pi:todo-lifecycle section is ensured there (idempotent marker, atomic) and migrated out of the legacy ~/.dsh/AGENTS.md commands.ts CommandService: parse + dual-channel dispatch + autocomplete btw.ts /btw by-the-way side questions (TUI-owned, ADR 0001): arg parsing (--model override), read-only recent-conversation snapshot builder, single-flight queue, stream consumption + controller over structural slices (unit-tested); idle main line refuses; nothing enters the session log or the main line btw-overlay.ts the btw overlay: framed panel bound to one run state (setText streaming, Markdown once on settle), Esc close via the focus contract, PanelHost two-step wiring (BtwOverlayWire) notice-bridge.ts shared notice bridge — the ONLY channel for operator traces (invalid dsh-tui.retention/resume settings values, the settings-namespace registration failure, a missing userQuestions service, the retention result): emitNotice delivers straight to the TUI's sink once registered, else queues bounded pending (FIFO, cap 16) drained in order on registration; never registered (headless) → silently dropped — deliberately no flush timer / stderr fallback (a timer firing into a slow-starting TUI would write raw bytes over the alt-screen). /reload-safe by module-cache eviction + per-producer one-shot guards; a failed reload rolls back with module state intact → still-pending is consumed once by the restarted TUI (≤ 1 batch) frame.ts FramedOverlay: shared top/bottom ─ border for every popup ask-user.ts Ask User Question: pure state reducers (answer/declined envelopes, double-Esc machine, flat-row layout) + the DOCKED questions panel (Todos-panel box in the askUser dock slot above the editor — takes focus while open, the app keymap yields via dockedModalActive, no floating overlay) + per-question auto-answer timeouts (dual rule: idle window + absolute cap, both <= 0 disable; recommended = first option, plans never auto-approved, notes declared in the answer envelope; countdown rides the panel footer) + ctx.userQuestions provider registration custom-provider.ts /login "Custom provider…" entry: pure field parsers + the chained EditField form composing a hand-declared llm-pi-ai route (id/name/protocol/baseURL/models/key) selectors.ts /model (2-stage, embeddable via PanelHost), /think, /theme pickers preset-dialog.ts /preset switch confirmation: the pure reducer + panel (repair-dialog.ts pattern) offering FORK & switch (new session seeded with the conversation — completedTurnSeed slices the balanced prefix) / FRESH start / CANCEL whenever a live session exists, plus performPresetSwitch — the injectable three-way flow (confirm → forkCommit via bridge.forkCurrentSession, or commit = selection + the /new detach path); no live session applies directly startup-info.ts startup config readout under the welcome banner (mcp count, skills installed/total, collapsed plugin tree) + the exit-hint pure helpers (--profile/--resume parsing, resume-command formatting); collectStartupSummary is a best-effort loader+filesystem snapshot model-list.ts /model pure logic: row assembly (favorites pinned top, dim Hidden section), filter matcher, toggle/hide-guard helpers — data-in/data-out, unit-tested without a terminal model-profiles.ts /profile-switch + /profile-cfg pure storage: $DSH_HOME/ model-profiles.json (atomic, self-healing reads, seeded work/personal/other), name ops (create/rename/delete), captureAgentsSnapshot (save-current; snapshot semantics: non-empty entries override at compose time, empty/missing keys fall back to the frontmatter baseline — applying writes no agent file, see agent-runtime.ts); directory pins — `.dsh-profile` dot file discovered walking up from cwd (nearest wins), write/remove with a hand-edit refusal guard, bindWorkspaceProfile (switch-time binding, same guard) + boundProfileName (per-tree ● current) profile.ts /profile-switch switcher + /profile-cfg manager (agents.ts overlay pattern): switcher applies a profile through the /model chain (bridge selection) + binds the tree (.dsh-profile) — agent model/think values are NOT written anywhere, the registry composes them at spawn (src/agent-runtime.ts); `p` pins/unpins the cwd (.dsh-profile — new sessions in this tree auto-load the pinned profile); manager = roster table (n new, d double-press delete) → FieldPanel (m model via the SAME favorites/hidden pickModel table, t think, a agents sub-table, s save current, r rename, v review) agent-runtime.ts profile-aware agent runtime composition — the single import seam for the dsh-subagent-registry contract (workspaceProfileName / composeAgentRuntime / readModelProfilesDoc): composed DISPLAY values (frontmatter baseline ⊕ pinned-profile overrides) + the scoped EDIT path (pin → existing profile: model-profiles.json per-agent override, frontmatter untouched; else the frontmatter baseline); probes the registry at load and falls back to the built-in equivalent until it ships (per-field override, missing keys fall back to the baseline, thinking whitelisted against the effort ids) settings.ts /settings browser: categories, schema walk, write chain, add-provider flow (uses provider-catalog.ts) sessions.ts /session panel + /resume picker (ordered by last update: jsonl log mtimes via loadSessionLastUpdates — newest mtime/size across BOTH log suffixes, retention's vocabulary, best-effort fallback to createdAt; display filter: active within maxAgeDays and log size >= minBytes — knobs dual-layer configurable, same chain as retention: settings.yaml `dsh-tui.resume.*` explicit (USER layer via readSessionManagementExplicit) > DSH_TUI_RESUME_MAX_AGE_DAYS/_MIN_BYTES env > defaults 30d/1KB, resolved per picker open by resolveResumeConfig) history.ts /history browser (ADR 0003): the read-only two-pane look-back — left TablePanel of the browsed session's completed turns (live snapshot or sessionPersistence inspect cold read; snapshot at open, never live-updating), right pane rebuilt statically from the selected turn's events with a self-managed scroll window (overlays get no layout viewport, so pi-tui ScrollView cannot be used there); `f` forks at the selected turn (turnSeedSlice → confirmed dialog → bridge.forkCurrentSession on the CURRENT preset, live and cold sessions alike; dialog in history-fork.ts); HStack ≥100 columns / VStack below, chosen per render; fixed-window geometry — exactly overlayContentBudget() lines, short turns pad blank, only a resize re-derives (mounted 90%×85%); a →/←/Esc focus model between the panes (detail: ↑↓ line-scroll, PgUp/PgDn or `[`/`]` page, other keys inert; Esc grades detail → list → filter-clear → close; list cursor ▸ demotes to › while the detail is keyed); Enter/c refills the editor with the turn's user prompt (plain setText, never submitted; replaces any draft without confirmation — prompt-less turns decline); `s` swaps the browsed session through the /resume-style picker (`/` filters it); nothing writes a log or takes write ownership history-turns.ts /history pure fold: SessionEvent[] → completed HistoryTurn[] (turn/start…turn/end brackets; unclosed turns excluded; per-turn user prompts — claimed steer/ follow-up included, injected kinds not — step replies and the tool-name tally) + the row filter; unit-tested without a terminal skills-manager.ts /skills panel (standalone skill browser): ~/.agents/skills → ~/.dsh/skills symlinks via an idempotent install chain (skillSymlinkPaths + installSkillSymlink: same-source no-op incl. relative-target equivalence, different-source / physical-dest refusal, dangling repair gated to ENOENT/ELOOP) + per-item applyOneSkillChange (uninstall is symmetric: only symlinks are removed, physical file/dir refused) with an end-of-batch skillApplySummary (short reasons, 3-item cap + "+N more"); a failing item never aborts the remaining pending changes, the panel rescans either way with the summary riding above the rows, and an applying gate blocks Space/Enter mid-batch retention.ts startup session-log janitor: pure selector (keep 100 / 30 days, 24h idle guard on the count rule, protected set = current session ∪ in-flight /resume target, both exempt and slot-free) + store walk (skips symlinks, lstat semantics) + one-by-one fs.rm runner (rm failure → failed count, never aborts the pass); thresholds dual-layer configurable (settings.yaml `dsh-tui.retention.*` explicit, read from the descriptor USER layer via readSessionManagementExplicit — theme-settings.ts — > DSH_TUI_RETENTION_MAX_COUNT/_MAX_AGE_DAYS/ _MIN_IDLE_HOURS env > defaults; precedence settings > env > default, invalid settings emit one notice each via the shared bridge and fall to the next level, invalid env falls back silently; MAX_COUNT<=0 at the winning layer disables — the escape hatch for long-lived read-attach processes); result surfaces once as a transient notice above the footer via the shared notice bridge (emitNotice — src/notice-bridge.ts); fire-and-forget in apply() behind a globalThis one-shot (per process, /reload-safe); root resolved by the CORE convention ($DSH_HOME/sessions); exports SESSION_LOG_FILE_NAMES shared with the /resume walk reload.ts /reload hot-reload (cordis-plugin-hmr style) hotkeys.ts /hotkeys — keybindings.json contract + validation + the select-panel manager (FieldPanel + EditField, /agents style) theme-settings.ts dsh-tui settings namespace (applies: 'live') + watch sink theme/ palette.ts (GitHub light/dark) + index.ts (buildTheme, resolveTheme: env > preference > terminal detection — COLORFGBG sync guess, then CSI 996n / OSC 11 query + live CSI 997 follow for 'auto') + registry.ts (theme-file layer: parseThemeFile validates the 15 required + 8 derived JSON fields, scanThemeDir warn-skips invalid files, discoverThemes merges builtin themes/ with the user theme directory (~/.dsh/themes/, $DSH_HOME/themes when set) over the builtins by name; resolveTheme's optional registry arg resolves any registered name — /theme and DSH_TUI_THEME accept them too, unknown names fall back to detection) text.ts clipToWidth / visibleWidth — the only width vocabulary ``` Theme hot-switch chain (read this before touching anything theme-related): `settings mutate → scope.watch → applyThemeRef (index.ts) → renderer.setTheme (ReplayOp replay) + tui.applyTheme (canvas background + themeRef swap + editor rebuild) → one throttled render frame`. `auto` also follows the terminal: a CSI 996n/OSC 11 query refines the startup guess, and CSI 997 pushes repaint while the preference stays `auto` (see `stopTerminalFollow`). Theme file layer: the 20 built-in palettes live as JSON under `themes/` (10 light + 10 dark); user themes go in `~/.dsh/themes/` (or `$DSH_HOME/themes/` when `DSH_HOME` is set) and override same-name builtins. The palette contract is 15 required fields (name/dark + the canvas/fg/border/ status colors) plus 8 derived fields that are blended over `canvas` when omitted (accentMuted 0.25 dark / 0.18 light, success/danger/attentionMuted 0.25, thinkingPanelBg 0.25 dark / 0.12 light, toolPanelBg = accentMuted, panelBorder = borderDefault, panelBoxBorder 0.70). Registry tests live in test/theme-registry.test.mjs; the JSON schema is documented in docs/features/themes.md. ## Iron rules 1. **Render never re-scans.** Footer/stats read O(1) maintained counters only. Never call `getBranch()/getEntries()` or walk the session log inside `render()`. Every event does O(event) work. 2. **Streaming is `setText` on the existing component** — never removeChild+addChild per delta. Markdown parses once on the assembled `assistant/message`, never per token. 3. **All truncation goes through `clipToWidth`** (src/text.ts). Bare `String.length` clipping is banned: CJK full-width = 2 columns, graphemes never split. **Clip plain text BEFORE applying ANSI** — `clipToWidth` counts SGR fragments as visible columns (verified on pi-tui 0.84.2); `clipRow`/`clipPanelLine` (activity.ts) encodes that order. 4. **UI text is English-only** (user requirement). Chinese/emoji *content* must render correctly — that means width-safe clipping everywhere. 5. **TypeScript constraints** (tsconfig): `NodeNext` + `verbatimModuleSyntax` + `erasableSyntaxOnly` → - relative imports carry the `.ts` extension (`./foo.ts`); - `import type` for type-only imports; no enums/namespaces/parameter properties (`constructor(private x)` is a compile error); - `rewriteRelativeImportExtensions` handles the emit. 6. **Comments in English.** Commit messages in English, imperative mood. 7. **Never re-implement a dsh command.** Autocomplete comes from `ctx.commands.list(agent)`, execution from `ctx.commands.execute`. TUI-owned commands register both channels: `registerLocal` (direct dispatch when no live agent — never mint a throwaway session) + `ctx.commands.register`. 8. **Overlay focus contract**: every overlay resolves back through `restoreFocus` — callers must re-focus the *current* editor instance (it is rebuilt on theme swap). Two-stage pickers show the new overlay before hiding the old one (no focus flash). Stage-2 Esc abandons the whole pick. 9. **Resume replay split**: replay only `seq < firstLiveSeq` events — live events arrive again through the `session/event` subscription and would double-count. `assistant/chunk` is skipped in replay (the finalized message carries the full text). ## Quality gates - `pnpm check` (tsc --noEmit) must stay 0 errors. - `pnpm test` runs **1367 tests** across 80 test files (per-file totals below; verify after any new logic is added and update if numbers move. New pure logic → new test file under `test/` against built `lib/` (`node --test`, pretest builds). Update the totals and the per-file list here. The suite is fully green. - ask-user 119 + subagent-policy 44 + btw 45 + subagent-viewer 42 + sessions 44 + retention 40 + skills 36 + - live 39 + history-viewer 33 + session-reconcile 33 + keymap 31 + model-profiles 31 + clipboard 26 + usage 26 + pending-echo 26 + - login 25 + ask-user-timeout 24 + log-repair 24 + panels 24 + skills-manager 24 + startup-info 24 + preset 22 + steer-flow 22 + theme 22 + model-list 21 + - messages 20 + settings 21 + welcome 18 + provider-catalog 17 + agent-runtime 16 + hotkeys 16 + theme-canvas 16 + theme-settings 16 + - text 15 + theme-registry 15 + agent-manager 14 + history 13 + footer-hints 13 + stop-dialog 12 + session-ch-cache 12 + image-blocks 12 + custom-provider 12 + - theme-switch 11 + preset-dialog 11 + preflight-projcache 12 + frame 11 + i18n 10 + workspace-presets 9 + permission 9 + history-turns 9 + history-fork 9 + commands 9 + append-system 9 + - notice-bridge 8 + font-detect 8 + dev-upgrade 8 + theme-picker 8 + language-ask 8 + repair-session-log 7 + remote-tail 6 + quotes 7 + icons 7 + host-version 7 + - hard-exit 3 + tokens 6 + subagent-status-tool 6 + reload 6 + queue-panel 6 + install-font 6 + bridge-stop 6 + selection-copy 5 + markdown-latex 5 + mouse-mode 5 + projcache 5 + language-picker 5 + transcript-search 4 + skill 4 + schema-model 3 + plugin-inject 2 + session-dir 2. - e2e is tmux-driven: `tmux new-session -d -s dsh-tui -x 140 -y 36`, launch `dsh --profile tui`, drive keys, `capture-pane` for assertions (scenarios under `e2e/scenarios/`). Keep the 24-row terminal case in the matrix — overlay maxHeights are tuned for it. - Review cycle ("老法师"): findings are labeled by class — A (architecture / UX / focus), B (behavioral bugs), C (cosmetic). Fixes land with regression tests where the finding is testable. Do not start a new feature before the review round on the current one is closed. ## Config safety — read before any test or e2e - `~/.dsh/settings.yaml` and `~/.dsh/.credentials.yaml` are **LIVE configuration** of the user's dsh. Tests and e2e runs that touch them MUST snapshot both files first and restore them byte-for-byte afterwards (verify with a diff; the e2e gate requires an empty diff). - The user may be running their own dsh instances concurrently. The settings file is **last-write-wins**: never assume you own it, never rewrite whole sections from a stale snapshot. All writes go through `settings.mutate(ns, pathOps, revision)` (optimistic concurrency, one retry on `SettingsConflictError`) or service APIs that do their own `settings.replace` (e.g. `agentDefaultModel.saveSelection`). - The settings service mounts asynchronously in the injection fiber (~144ms transient `undefined`): wait bounded (the theme-settings registration promise pattern) and degrade, never hang startup. ## Known pi-tui 0.84.2 limitations (do not fight them) - **Layout does not descend into a plain Container**: a Container without a layout node renders by concatenation, so a nested ScrollView inside the transcript can never obtain a viewport. The fixed think/tool panels with a tail body (`dsh-tui.panelHeight`: '1' one row, '5'/'7'/'10' boxed rows, or 'all' with a bounded streaming tail and a 2000-line tool-result cap) are the accepted design — no inner scrolling. - **The canvas background is painted by our write-stream decorator, not by components**: rows are written with erase-line (`\x1b[2K`) + content, so unpainted rows would show the terminal's default background and a theme switch would leave it frozen (most visible inside cmux/gostty). `src/canvas-terminal.ts` wraps the `ProcessTerminal` handed to `TuiAltScreen` and injects the canvas SGR at two points: before every `\x1b[2K`/`\x1b[2J` (BCE — terminals fill erased regions with the current SGR background, covering row tails) and after every background-clearing SGR reset (`\x1b[0m`, `\x1b[m`, `\x1b[0;…m`, `\x1b[49m` — content after a reset would otherwise print cells with the terminal default background, punching holes into the canvas; color-sets whose params merely contain a 0 channel are NOT resets). Zero pi-tui patches. Diff-rendered rows the renderer skips keep their last paint, which is why `applyTheme` must force a full redraw via `requestRender(true)`. `DSH_TUI_TRANSPARENT=1` (checked in `src/tui.ts`) opts back into the see-through canvas. The alt-screen exit dump passes through unpainted (decorator shutoff on `EXIT_ALT_SCREEN`). - **SelectListTheme has no background hook for unselected rows**: the value part of unselected rows renders raw (`renderItem` → `prefix + truncatedValue`), so it cannot get the `canvasSubtle` backdrop. Only the selected row, descriptions, scroll info and no-match line are themed. - **A truncated label drops the selected-row backdrop after the cut**: pi-tui 0.84.2's `truncateToWidth` terminates any truncated result with a full `\x1b[0m` reset (`finalizeTruncatedResult`), so when a SELECTED skill row's label is cut, everything after the cut — the spacing and the description — loses the selected background/bold (the `\x1b[0m` ends `selectedText`'s span early). Pre-existing upstream behaviour, unrelated to the whole-line italic tweak (the italic off-code is `\x1b[23m`; the non-truncated backdrop path is regression-tested in test/skills.test.mjs). - **Input has no masking**: secret fields are rendered by our own `EditField` dot-row renderer (maskLine) over a real Input; the value never reaches the render output. - **SettingsList's search input row is pushed raw** (settings-list.js, first line with `enableSearch`), no theme injection. Also: its search matches `label` only (fuzzyFilter) — English labels carry the searchable keywords; `done(undefined)` closes a submenu without changing anything.