# 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)
                  + 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 **1288 tests** across 74 files (verified by
  `node --test test/*.mjs`; 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 + btw 45 + subagent-viewer 41 + subagent-policy 44 + retention 39 + skills 36 +
  - live 35 + sessions 35 + session-reconcile 33 + history-viewer 33 + keymap 31 + model-profiles 31 +
  - usage 26 + pending-echo 26 + clipboard 26 + login 25 + log-repair 25 +
    startup-info 24 + skills-manager 24 + panels 24 +
  - steer-flow 22 + preset 22 + theme 21 + model-list 21 + messages 20 + settings 19 +
    welcome 18 + provider-catalog 17 + theme-settings 16 + theme-canvas 16 +
    hotkeys 16 + agent-runtime 16 +
  - text 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 11 + frame 11 +
  - session-dir 2 + 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 +
    repair-session-log 7 + remote-tail 7 + quotes 7 + icons 7 + host-version 7 +
  - 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 + transcript-search 4 +
    profile-apply 4 + schema-model 3 + plugin-inject 2 + skill 4.
- 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.
