@huseynsnmz/pi-suite 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +88 -2
  4. package/SECURITY.md +5 -0
  5. package/package.json +112 -4
  6. package/packages/board/CHANGELOG.md +340 -0
  7. package/packages/board/CONTRIBUTING.md +7 -0
  8. package/packages/board/LICENSE +21 -0
  9. package/packages/board/README.md +88 -0
  10. package/packages/board/SECURITY.md +19 -0
  11. package/packages/board/docs/how-it-works.md +31 -0
  12. package/packages/board/docs/reference.md +278 -0
  13. package/packages/board/package.json +81 -0
  14. package/packages/board/runner/job-runner-legacy.mjs +68 -0
  15. package/packages/board/runner/job-runner.mjs +834 -0
  16. package/packages/board/runner/pty-runner-legacy.mjs +50 -0
  17. package/packages/board/runner/pty-runner.mjs +2175 -0
  18. package/packages/board/runner/state-coordinator.mjs +673 -0
  19. package/packages/board/runner/state-runner.mjs +203 -0
  20. package/packages/board/runner/title-runner.mjs +80 -0
  21. package/packages/board/src/commands/agent-board.ts +394 -0
  22. package/packages/board/src/commands/attach-decision.mjs +66 -0
  23. package/packages/board/src/commands/attach-flow.ts +251 -0
  24. package/packages/board/src/commands/bg.ts +103 -0
  25. package/packages/board/src/commands/board-model.ts +21 -0
  26. package/packages/board/src/core/atomic.mjs +184 -0
  27. package/packages/board/src/core/attach-delete.mjs +24 -0
  28. package/packages/board/src/core/attach-frame-proof.mjs +870 -0
  29. package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
  30. package/packages/board/src/core/attach-frame-stream.mjs +266 -0
  31. package/packages/board/src/core/attach-view-proof.mjs +709 -0
  32. package/packages/board/src/core/auto-state.mjs +377 -0
  33. package/packages/board/src/core/background-runs.mjs +273 -0
  34. package/packages/board/src/core/code-refs-store.mjs +315 -0
  35. package/packages/board/src/core/code-refs.mjs +913 -0
  36. package/packages/board/src/core/config.mjs +77 -0
  37. package/packages/board/src/core/control-protocol.mjs +482 -0
  38. package/packages/board/src/core/coordinator-client.mjs +633 -0
  39. package/packages/board/src/core/coordinator-journal.mjs +282 -0
  40. package/packages/board/src/core/coordinator-protocol.mjs +14 -0
  41. package/packages/board/src/core/cwd-stats.mjs +114 -0
  42. package/packages/board/src/core/dashboard-render.mjs +10 -0
  43. package/packages/board/src/core/derive.mjs +117 -0
  44. package/packages/board/src/core/diagnostics.mjs +133 -0
  45. package/packages/board/src/core/double-press.mjs +26 -0
  46. package/packages/board/src/core/editor-state-reporter.mjs +112 -0
  47. package/packages/board/src/core/events.mjs +286 -0
  48. package/packages/board/src/core/evidence.mjs +245 -0
  49. package/packages/board/src/core/follow-up-queue.mjs +211 -0
  50. package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
  51. package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
  52. package/packages/board/src/core/frame-witness-update.mjs +35 -0
  53. package/packages/board/src/core/heuristics.mjs +313 -0
  54. package/packages/board/src/core/hold-input.mjs +27 -0
  55. package/packages/board/src/core/host-coordination.mjs +300 -0
  56. package/packages/board/src/core/host-crash.mjs +79 -0
  57. package/packages/board/src/core/host-probe.mjs +207 -0
  58. package/packages/board/src/core/host-protocol.mjs +73 -0
  59. package/packages/board/src/core/ids.mjs +35 -0
  60. package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
  61. package/packages/board/src/core/invocation.mjs +43 -0
  62. package/packages/board/src/core/launch-options.mjs +418 -0
  63. package/packages/board/src/core/launch.mjs +157 -0
  64. package/packages/board/src/core/load-config.mjs +4 -0
  65. package/packages/board/src/core/locks.mjs +530 -0
  66. package/packages/board/src/core/paths.mjs +183 -0
  67. package/packages/board/src/core/pid.mjs +75 -0
  68. package/packages/board/src/core/prewarm-schedule.mjs +41 -0
  69. package/packages/board/src/core/process-identity-probe.mjs +228 -0
  70. package/packages/board/src/core/prompt-transport.mjs +13 -0
  71. package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
  72. package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
  73. package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
  74. package/packages/board/src/core/pty-attach-render.mjs +229 -0
  75. package/packages/board/src/core/pty-links.mjs +81 -0
  76. package/packages/board/src/core/pty-scroll.mjs +522 -0
  77. package/packages/board/src/core/pty-support.mjs +327 -0
  78. package/packages/board/src/core/repo.mjs +103 -0
  79. package/packages/board/src/core/rows.mjs +414 -0
  80. package/packages/board/src/core/screen-log-gc.mjs +198 -0
  81. package/packages/board/src/core/screen-log.mjs +160 -0
  82. package/packages/board/src/core/session-view.mjs +174 -0
  83. package/packages/board/src/core/slash-commands.mjs +52 -0
  84. package/packages/board/src/core/state-commands.mjs +757 -0
  85. package/packages/board/src/core/status-consistency.mjs +98 -0
  86. package/packages/board/src/core/steering-prompts.mjs +34 -0
  87. package/packages/board/src/core/steering.mjs +133 -0
  88. package/packages/board/src/core/stop-latch.mjs +41 -0
  89. package/packages/board/src/core/store.mjs +523 -0
  90. package/packages/board/src/core/suspend.mjs +36 -0
  91. package/packages/board/src/core/terminal-attach-client.mjs +981 -0
  92. package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
  93. package/packages/board/src/core/terminal-model.mjs +222 -0
  94. package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
  95. package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
  96. package/packages/board/src/core/terminal-snapshot.mjs +440 -0
  97. package/packages/board/src/core/test-knobs.mjs +28 -0
  98. package/packages/board/src/core/title.mjs +43 -0
  99. package/packages/board/src/core/types.mjs +414 -0
  100. package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
  101. package/packages/board/src/core/worktree.mjs +65 -0
  102. package/packages/board/src/index.ts +303 -0
  103. package/packages/board/src/runtime/service.mjs +3222 -0
  104. package/packages/board/src/ui/chrome.ts +20 -0
  105. package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
  106. package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
  107. package/packages/board/src/ui/dashboard.ts +2237 -0
  108. package/packages/board/src/ui/double-left.ts +30 -0
  109. package/packages/board/src/ui/external-editor.ts +25 -0
  110. package/packages/board/src/ui/focus.ts +32 -0
  111. package/packages/board/src/ui/pty-attach.ts +3184 -0
  112. package/packages/board/src/ui/waiting.ts +30 -0
  113. package/packages/commands/LICENSE +21 -0
  114. package/packages/commands/README.md +19 -0
  115. package/packages/commands/package.json +22 -0
  116. package/packages/commands/src/index.ts +43 -0
  117. package/packages/commands/src/security-review.ts +109 -0
  118. package/packages/frame/CHANGELOG.md +113 -0
  119. package/packages/frame/CONTRIBUTING.md +7 -0
  120. package/packages/frame/LICENSE +22 -0
  121. package/packages/frame/README.md +105 -0
  122. package/packages/frame/SECURITY.md +16 -0
  123. package/packages/frame/docs/how-it-works.md +53 -0
  124. package/packages/frame/docs/reference.md +78 -0
  125. package/packages/frame/package.json +72 -0
  126. package/packages/frame/src/data/git.ts +90 -0
  127. package/packages/frame/src/data/github.ts +140 -0
  128. package/packages/frame/src/data/gitlab.ts +63 -0
  129. package/packages/frame/src/data/mode.ts +49 -0
  130. package/packages/frame/src/data/past-prompts.ts +57 -0
  131. package/packages/frame/src/data/runner.ts +32 -0
  132. package/packages/frame/src/data/snapshot.ts +223 -0
  133. package/packages/frame/src/data/start-notes.ts +104 -0
  134. package/packages/frame/src/data/telemetry.ts +337 -0
  135. package/packages/frame/src/data/todos.ts +162 -0
  136. package/packages/frame/src/data/totals.ts +94 -0
  137. package/packages/frame/src/data/visits.ts +45 -0
  138. package/packages/frame/src/index.ts +696 -0
  139. package/packages/frame/src/kit/LICENSE +21 -0
  140. package/packages/frame/src/kit/app/attribution.ts +30 -0
  141. package/packages/frame/src/kit/app/builder.ts +186 -0
  142. package/packages/frame/src/kit/app/claim.ts +27 -0
  143. package/packages/frame/src/kit/app/feature.ts +91 -0
  144. package/packages/frame/src/kit/app/host.ts +214 -0
  145. package/packages/frame/src/kit/app/renderers.ts +62 -0
  146. package/packages/frame/src/kit/app/scope.ts +125 -0
  147. package/packages/frame/src/kit/board/model.ts +337 -0
  148. package/packages/frame/src/kit/contract.ts +79 -0
  149. package/packages/frame/src/kit/contracts/accounts.ts +33 -0
  150. package/packages/frame/src/kit/contracts/ask.ts +120 -0
  151. package/packages/frame/src/kit/contracts/board.ts +6 -0
  152. package/packages/frame/src/kit/contracts/by.ts +22 -0
  153. package/packages/frame/src/kit/contracts/calls.ts +46 -0
  154. package/packages/frame/src/kit/contracts/events.ts +89 -0
  155. package/packages/frame/src/kit/contracts/mode.ts +26 -0
  156. package/packages/frame/src/kit/contracts/names.ts +12 -0
  157. package/packages/frame/src/kit/contracts/peers.ts +77 -0
  158. package/packages/frame/src/kit/contracts/prompts.ts +8 -0
  159. package/packages/frame/src/kit/contracts/screen.ts +75 -0
  160. package/packages/frame/src/kit/contracts/skills.ts +81 -0
  161. package/packages/frame/src/kit/contracts/sources.ts +81 -0
  162. package/packages/frame/src/kit/contracts/views.ts +42 -0
  163. package/packages/frame/src/kit/control.ts +59 -0
  164. package/packages/frame/src/kit/dark-theme.ts +41 -0
  165. package/packages/frame/src/kit/decode.ts +210 -0
  166. package/packages/frame/src/kit/events/log.ts +313 -0
  167. package/packages/frame/src/kit/format.ts +25 -0
  168. package/packages/frame/src/kit/host.ts +4 -0
  169. package/packages/frame/src/kit/index.ts +25 -0
  170. package/packages/frame/src/kit/legacy-tasks.ts +39 -0
  171. package/packages/frame/src/kit/overlay-images.ts +36 -0
  172. package/packages/frame/src/kit/paths.ts +16 -0
  173. package/packages/frame/src/kit/screen/client.ts +74 -0
  174. package/packages/frame/src/kit/screen/feature.ts +64 -0
  175. package/packages/frame/src/kit/screen/model.ts +182 -0
  176. package/packages/frame/src/kit/screen/view.ts +838 -0
  177. package/packages/frame/src/kit/settings/files.ts +90 -0
  178. package/packages/frame/src/kit/settings/setting.ts +57 -0
  179. package/packages/frame/src/kit/settings/store.ts +192 -0
  180. package/packages/frame/src/kit/sources/file.ts +129 -0
  181. package/packages/frame/src/kit/sources/follow.ts +155 -0
  182. package/packages/frame/src/kit/testing.ts +260 -0
  183. package/packages/frame/src/kit/totals.ts +72 -0
  184. package/packages/frame/src/kit/ui/entry.ts +75 -0
  185. package/packages/frame/src/kit/ui/frame.ts +212 -0
  186. package/packages/frame/src/kit/ui/glyphs.ts +132 -0
  187. package/packages/frame/src/kit/ui/scroll.ts +14 -0
  188. package/packages/frame/src/render/diff.ts +157 -0
  189. package/packages/frame/src/render/fit.ts +86 -0
  190. package/packages/frame/src/render/format.ts +108 -0
  191. package/packages/frame/src/render/frame.ts +106 -0
  192. package/packages/frame/src/render/glyphs.ts +189 -0
  193. package/packages/frame/src/render/keys.ts +43 -0
  194. package/packages/frame/src/render/lead.ts +37 -0
  195. package/packages/frame/src/render/paint.ts +135 -0
  196. package/packages/frame/src/render/safe.ts +19 -0
  197. package/packages/frame/src/render/segments.ts +735 -0
  198. package/packages/frame/src/render/streaming.ts +223 -0
  199. package/packages/frame/src/render/timing.ts +106 -0
  200. package/packages/frame/src/render/tools.ts +167 -0
  201. package/packages/frame/src/settings.ts +105 -0
  202. package/packages/frame/src/ui/editor.ts +272 -0
  203. package/packages/frame/src/ui/effort.ts +36 -0
  204. package/packages/frame/src/ui/footer.ts +165 -0
  205. package/packages/frame/src/ui/header.ts +362 -0
  206. package/packages/frame/src/ui/history-search.ts +23 -0
  207. package/packages/frame/src/ui/interrupt.ts +21 -0
  208. package/packages/frame/src/ui/outputs.ts +170 -0
  209. package/packages/frame/src/ui/pi-entries.ts +298 -0
  210. package/packages/frame/src/ui/previews.ts +72 -0
  211. package/packages/frame/src/ui/queue.ts +75 -0
  212. package/packages/frame/src/ui/screen.ts +59 -0
  213. package/packages/frame/src/ui/timings.ts +48 -0
  214. package/packages/frame/src/ui/todos.ts +205 -0
  215. package/packages/frame/src/ui/tool-frame.ts +595 -0
  216. package/packages/frame/src/ui/working.ts +25 -0
  217. package/packages/frame/src/view/state.ts +97 -0
  218. package/packages/frame/src/view/transcript.ts +175 -0
  219. package/packages/frame/src/view/view.ts +162 -0
  220. package/packages/memory/CONTRIBUTING.md +7 -0
  221. package/packages/memory/LICENSE +21 -0
  222. package/packages/memory/README.md +44 -0
  223. package/packages/memory/SECURITY.md +20 -0
  224. package/packages/memory/package.json +55 -0
  225. package/packages/memory/skills/review-memory/SKILL.md +48 -0
  226. package/packages/memory/src/index.ts +163 -0
  227. package/packages/memory/src/prompt.ts +25 -0
  228. package/packages/memory/src/review.ts +150 -0
  229. package/packages/memory/src/secrets.ts +22 -0
  230. package/packages/memory/src/store.ts +173 -0
  231. package/packages/modes/CHANGELOG.md +104 -0
  232. package/packages/modes/CONTRIBUTING.md +7 -0
  233. package/packages/modes/LICENSE +21 -0
  234. package/packages/modes/README.md +174 -0
  235. package/packages/modes/SECURITY.md +17 -0
  236. package/packages/modes/package.json +55 -0
  237. package/packages/modes/skills/review-permissions/SKILL.md +54 -0
  238. package/packages/modes/src/bash.ts +218 -0
  239. package/packages/modes/src/deny-off.ts +62 -0
  240. package/packages/modes/src/git-safety.ts +72 -0
  241. package/packages/modes/src/index.ts +338 -0
  242. package/packages/modes/src/interview.ts +206 -0
  243. package/packages/modes/src/judge-gate.ts +113 -0
  244. package/packages/modes/src/modes.ts +121 -0
  245. package/packages/modes/src/review/apply.mjs +156 -0
  246. package/packages/modes/src/review/collect.mjs +208 -0
  247. package/packages/modes/src/review/tools.ts +71 -0
  248. package/packages/modes/src/sandbox.ts +37 -0
  249. package/packages/router/CHANGELOG.md +8 -0
  250. package/packages/router/LICENSE +21 -0
  251. package/packages/router/README.md +90 -0
  252. package/packages/router/package.json +51 -0
  253. package/packages/router/src/config.ts +65 -0
  254. package/packages/router/src/index.ts +172 -0
  255. package/packages/router/src/judge.ts +51 -0
  256. package/packages/router/src/route.ts +67 -0
  257. package/packages/web/CHANGELOG.md +36 -0
  258. package/packages/web/LICENSE +21 -0
  259. package/packages/web/README.md +87 -0
  260. package/packages/web/SECURITY.md +13 -0
  261. package/packages/web/package.json +59 -0
  262. package/packages/web/src/config.ts +109 -0
  263. package/packages/web/src/dnsfilter.ts +97 -0
  264. package/packages/web/src/extract.ts +65 -0
  265. package/packages/web/src/index.ts +217 -0
  266. package/packages/web/src/markdown.ts +122 -0
  267. package/packages/web/src/net.ts +109 -0
  268. package/packages/web/src/reader.ts +73 -0
  269. package/packages/web/src/sanitize.ts +50 -0
  270. package/packages/web/src/search.ts +76 -0
  271. package/packages/web/src/secrets.ts +22 -0
  272. package/packages/web/src/validate.ts +186 -0
  273. package/src/index.ts +156 -0
  274. package/src/settings.ts +79 -0
@@ -0,0 +1,2237 @@
1
+ /**
2
+ * The agent-board dashboard: a single full-screen `ctx.ui.custom` component that owns the
3
+ * list, peek/reply, dispatch, filter, rename, and confirm modes plus a live poll loop.
4
+ *
5
+ * It resolves (`done`) only for actions the command handler must run after the surface
6
+ * closes — attaching to a session (tears down the current session) or quitting. Everything
7
+ * else (dispatch, reply, stop, pin, rename, archive) is handled in-place against the store.
8
+ */
9
+ import { suspendToShell } from "../core/suspend.mjs";
10
+ import { readFileSync } from "node:fs";
11
+ import { CustomEditor } from "@earendil-works/pi-coding-agent";
12
+ import type { Component, EditorTheme, KeybindingsManager, TUI } from "@earendil-works/pi-tui";
13
+ import { CombinedAutocompleteProvider, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
14
+ import { createDoublePress, QUIT_PRESS_MS } from "../core/double-press.mjs";
15
+ import { runsInPi } from "../core/slash-commands.mjs";
16
+ import { readConfig, writeConfig } from "../core/config.mjs";
17
+ import { editExternally } from "./external-editor.ts";
18
+ import { requestDashboardRender } from "../core/dashboard-render.mjs";
19
+ import { normalizeGenericStatusText } from "../core/derive.mjs";
20
+ import { firstSentence, truncate } from "../core/heuristics.mjs";
21
+ import {
22
+ browseTabCompletion,
23
+ canonicalModelRef,
24
+ clampThinkingLevel,
25
+ listDirectorySuggestions,
26
+ existingCwdCandidates,
27
+ modelRefAvailable,
28
+ nextCwdPickerState,
29
+ resolveDirectoryValue,
30
+ resolveLaunchContext,
31
+ supportedThinkingLevels,
32
+ } from "../core/launch-options.mjs";
33
+ import { createPrewarmScheduler } from "../core/prewarm-schedule.mjs";
34
+ import { planDashboardAttach, planPrewarm, replyNotice } from "./dashboard-decisions.mjs";
35
+ import { ensureCwdStatsSeeded, rankedCwdCandidates, recordCwdLaunch } from "../core/cwd-stats.mjs";
36
+ import { filterRows, groupRowsByFolder, groupRowsByDirectory, neighborAfterRemoval, rowState, stateGlyph } from "../core/rows.mjs";
37
+ import { loadSessionView } from "../core/session-view.mjs";
38
+ import { GROUP_LABELS } from "../core/types.mjs";
39
+ import { buildEvidencePanel } from "./dashboard-evidence.mjs";
40
+ import { type Row } from "../core/store.mjs";
41
+ import type { createService } from "../runtime/service.mjs";
42
+
43
+ type Service = ReturnType<typeof createService>;
44
+ type PtyIssue = {
45
+ id: string;
46
+ title: string;
47
+ statusLabel: string;
48
+ summary: string;
49
+ fixHint: string;
50
+ steps: string[];
51
+ rawReason: string;
52
+ };
53
+ type PtyHealth = { ok: boolean; reason: string | null; staleHosts: number; liveHosts: number; issue?: PtyIssue | null };
54
+
55
+ interface ThemeLike {
56
+ fg(color: string, text: string): string;
57
+ bg(color: string, text: string): string;
58
+ bold(text: string): string;
59
+ italic(text: string): string;
60
+ strikethrough(text: string): string;
61
+ }
62
+
63
+ export type DashboardResult =
64
+ | { action: "exit" }
65
+ | { action: "attach"; viewId: string; stopFirst: boolean }
66
+ | { action: "quit" }
67
+ | { action: "command"; text: string };
68
+
69
+ type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
70
+ type LaunchModel = {
71
+ provider: string;
72
+ id: string;
73
+ name?: string;
74
+ reasoning?: boolean;
75
+ thinkingLevelMap?: Partial<Record<ThinkingLevel, string | null>>;
76
+ };
77
+ type LaunchChoice = { model: LaunchModel; thinkingLevel?: ThinkingLevel };
78
+ type LaunchPicker = "cwd" | "model" | "thinking" | null;
79
+
80
+ type Mode = "list" | "select" | "dispatch" | "filter" | "peek" | "reply" | "rename" | "confirm" | "help" | "ptyHelp" | "session" | "evidence" | "launch";
81
+
82
+ interface PendingConfirm {
83
+ prompt: string;
84
+ onYes: () => void;
85
+ returnMode?: Mode;
86
+ }
87
+
88
+ type ReturnableMode = Exclude<Mode, "help">;
89
+
90
+ type InputNoticeColor = "accent" | "success" | "warning" | "muted" | "dim";
91
+ type FlashLevel = "info" | "warn" | "error";
92
+
93
+ /** Long enough to read, and longer the more it matters. */
94
+ const NOTICE_MS: Record<FlashLevel, number> = { info: 3_000, warn: 5_000, error: 8_000 };
95
+ const NOTICE_COLOR: Record<FlashLevel, "accent" | "warning" | "error"> = { info: "accent", warn: "warning", error: "error" };
96
+
97
+ interface InputNotice {
98
+ text: string;
99
+ color: InputNoticeColor | "error";
100
+ expiresAt: number;
101
+ }
102
+
103
+ interface CwdCandidate {
104
+ path: string;
105
+ count: number;
106
+ }
107
+
108
+ interface LaunchState {
109
+ fieldIndex: number;
110
+ picker: LaunchPicker;
111
+ action: "background" | "attach";
112
+ cwd: string;
113
+ cwdQuery: string;
114
+ cwdSuggestions: string[];
115
+ cwdSuggestionIndex: number;
116
+ cwdRanked: CwdCandidate[];
117
+ cwdPickerMode: "favorites" | "browse";
118
+ choices: LaunchChoice[];
119
+ scopeSource: "scoped" | "all";
120
+ model: LaunchModel | null;
121
+ modelQuery: string;
122
+ modelFiltered: LaunchChoice[];
123
+ modelIndex: number;
124
+ thinking: ThinkingLevel;
125
+ thinkingOptions: ThinkingLevel[];
126
+ thinkingIndex: number;
127
+ }
128
+
129
+ export interface DashboardDeps {
130
+ service: Service;
131
+ root: string;
132
+ defaultCwd: string;
133
+ currentSessionFile?: string | null;
134
+ availableModels: LaunchModel[];
135
+ currentModel: LaunchModel | null;
136
+ currentThinkingLevel: ThinkingLevel;
137
+ initialSelectedId?: string | null;
138
+ initialNotice?: string;
139
+ commands?: { name: string; description?: string; source: string }[];
140
+ }
141
+
142
+ export class DashboardComponent implements Component {
143
+ private mode: Mode = "list";
144
+ private rows: Row[] = [];
145
+ private orderedIds: string[] = [];
146
+ private selectedId: string | null = null;
147
+ private selectedIds = new Set<string>();
148
+ private input = "";
149
+ private filterQuery = "";
150
+ private pending: PendingConfirm | null = null;
151
+ private deleteArm: { id: string; at: number } | null = null;
152
+ private readonly quitPresses = createDoublePress(Date.now, QUIT_PRESS_MS);
153
+ private groupBy: "state" | "directory" = readConfig().groupBy === "directory" ? "directory" : "state";
154
+ private readonly groupOf = new Map<string, string>();
155
+ private readonly neighborsOf = new Map<string, string>();
156
+ private readonly folderOf = new Map<string, string>();
157
+ private helpReturnMode: ReturnableMode = "list";
158
+ private ptyHelpReturnMode: ReturnableMode = "list";
159
+ private peekId: string | null = null;
160
+ private scrollTop = 0;
161
+ private sessionScrollTop = 0;
162
+ private evidenceScrollTop = 0;
163
+ private prewarmedId: string | null = null;
164
+ private readonly prewarmScheduler = createPrewarmScheduler(() => this.prewarmSelected(), 200);
165
+ private inputNotice: InputNotice | null = null;
166
+ /** A changed filter starts over at the top. Anything else that takes the cursor's row keeps it nearby. */
167
+ private filterAtLastRefresh = "";
168
+ private launch: LaunchState | null = null;
169
+ private lastLaunchPrefs: { cwd: string | null; model: string | null; thinkingLevel: ThinkingLevel | null } | null = null;
170
+ /** First frame after mount must clear the screen: pi-tui's first render
171
+ * "assumes clean screen" (fullRender(false)) and overlays never get
172
+ * clearOnShrink — crash output / dirty bottoms would persist (issue #88). */
173
+ private needsFullClear = true;
174
+ /** Content line count of the previous frame BEFORE spacer/padding fill
175
+ * (padding always fills the terminal height, so padded counts never
176
+ * shrink — shrink detection must run on pre-fill counts). */
177
+ private lastContentLineCount: number | null = null;
178
+ private frameContentLineCount = 0;
179
+ private readonly editor: CustomEditor;
180
+
181
+ constructor(
182
+ private readonly tui: TUI,
183
+ private readonly theme: ThemeLike,
184
+ private readonly keybindings: KeybindingsManager,
185
+ private readonly done: (result: DashboardResult) => void,
186
+ private readonly deps: DashboardDeps,
187
+ ) {
188
+ stageTheme = theme;
189
+ this.editor = new CustomEditor(tui, editorTheme(theme), keybindings as never, { paddingX: 0 });
190
+ this.editor.onChange = (text) => {
191
+ this.input = this.editor.getExpandedText();
192
+ if (this.mode === "filter") {
193
+ this.filterQuery = text;
194
+ this.refresh();
195
+ }
196
+ };
197
+ this.editor.onSubmit = (text) => this.submitEditor(text);
198
+ this.editor.setAutocompleteProvider(this.commandMenu());
199
+ this.selectedId = deps.initialSelectedId ?? null;
200
+ // Issue #147: the post-delete reopen carries the Deleted notice here —
201
+ // the component is new each open, so an inline notice cannot travel.
202
+ if (deps.initialNotice) this.notice(deps.initialNotice, "info");
203
+ try {
204
+ this.lastLaunchPrefs = this.deps.service.getLaunchPrefs?.() ?? null;
205
+ } catch {
206
+ this.lastLaunchPrefs = null;
207
+ }
208
+ this.refresh();
209
+ this.prewarmSelected();
210
+ }
211
+
212
+ // ---- data ---------------------------------------------------------------
213
+
214
+ refresh(): void {
215
+ const previousSelected = this.selectedId;
216
+ const previousIds = this.orderedIds;
217
+ const previousGroups = new Map(this.groupOf);
218
+ const filterChanged = this.filterQuery !== this.filterAtLastRefresh;
219
+ this.filterAtLastRefresh = this.filterQuery;
220
+ const all = this.deps.service.rows();
221
+ this.rows = this.filterQuery ? filterRows(all, this.filterQuery) : all;
222
+ this.groupOf.clear();
223
+ this.neighborsOf.clear();
224
+ this.folderOf.clear();
225
+ const place = (group: string, folder: string, ids: string[]) => {
226
+ for (const id of ids) {
227
+ this.groupOf.set(id, group);
228
+ this.folderOf.set(id, folder);
229
+ this.neighborsOf.set(id, `${group}\0${folder}`);
230
+ }
231
+ return ids;
232
+ };
233
+ this.orderedIds = this.groupBy === "directory"
234
+ ? groupRowsByDirectory(this.rows, Date.now()).flatMap((f) => place(f.key, f.key, f.rows.map((r) => r.id)))
235
+ : groupRowsByFolder(this.rows, Date.now()).flatMap((g) => g.folders.flatMap((f) => place(g.state, f.key, f.rows.map((r) => r.id))));
236
+ const visibleIds = new Set(this.orderedIds);
237
+ this.selectedIds = new Set([...this.selectedIds].filter((id) => visibleIds.has(id)));
238
+ if (this.orderedIds.length === 0) {
239
+ this.selectedId = null;
240
+ if (this.mode === "select") this.mode = "list";
241
+ } else if (!this.selectedId || !this.orderedIds.includes(this.selectedId)) {
242
+ const nearby = this.selectedId && !filterChanged ? neighborAfterRemoval(previousIds, previousGroups, this.selectedId, (id: string) => visibleIds.has(id)) : null;
243
+ this.selectedId = nearby ?? this.orderedIds[0];
244
+ }
245
+ if (this.selectedId && this.selectedId !== previousSelected) this.prewarmScheduler.schedule();
246
+ }
247
+
248
+ private selectedRow(): Row | null {
249
+ const id = this.mode === "peek" || this.mode === "reply" ? this.peekId : this.selectedId;
250
+ return id ? (this.rows.find((r) => r.meta.id === id) ?? this.deps.service.row(id)) : null;
251
+ }
252
+
253
+ private selectionCount(): number {
254
+ return this.selectedIds.size;
255
+ }
256
+
257
+ private unreadCount(): number {
258
+ return this.rows.filter((row) => {
259
+ const lastVisitedAt = row.state?.lastVisitedAt ?? null;
260
+ const lastAgentActivityAt = row.state?.lastAgentActivityAt ?? null;
261
+ return lastAgentActivityAt !== null && (lastVisitedAt === null || lastAgentActivityAt > lastVisitedAt);
262
+ }).length;
263
+ }
264
+
265
+ private isSelectedForBatch(viewId: string): boolean {
266
+ return this.selectedIds.has(viewId);
267
+ }
268
+
269
+ private startSelectionMode(): void {
270
+ if (!this.selectedId) return;
271
+ this.mode = "select";
272
+ this.selectedIds.clear();
273
+ this.notifyInputState("SELECT mode — 0 selected", "accent");
274
+ }
275
+
276
+ private openHelp(returnMode: ReturnableMode): void {
277
+ this.helpReturnMode = returnMode;
278
+ this.mode = "help";
279
+ }
280
+
281
+ private openPtyHelp(returnMode: ReturnableMode): void {
282
+ this.ptyHelpReturnMode = returnMode;
283
+ this.mode = "ptyHelp";
284
+ }
285
+
286
+ private exitSelectionMode(clear = true): void {
287
+ if (clear) this.selectedIds.clear();
288
+ this.mode = "list";
289
+
290
+ }
291
+
292
+ private toggleSelectedCurrent(): void {
293
+ if (!this.selectedId) return;
294
+ if (this.selectedIds.has(this.selectedId)) this.selectedIds.delete(this.selectedId);
295
+ else this.selectedIds.add(this.selectedId);
296
+ }
297
+
298
+ private selectAllVisible(): void {
299
+ this.selectedIds = new Set(this.orderedIds);
300
+ }
301
+
302
+ private selectedBatchRows(): Row[] {
303
+ return [...this.selectedIds].map((id) => this.deps.service.row(id)).filter((row): row is Row => Boolean(row));
304
+ }
305
+
306
+ private moveSelection(delta: number): void {
307
+ if (this.orderedIds.length === 0) return;
308
+ const cur = this.selectedId ? this.orderedIds.indexOf(this.selectedId) : 0;
309
+ const len = this.orderedIds.length;
310
+ // Wrap around both ends: down past last -> first, up past first -> last.
311
+ const next = (((cur < 0 ? 0 : cur) + delta) % len + len) % len;
312
+ const nextId = this.orderedIds[next];
313
+ if (nextId === this.selectedId) return;
314
+ this.selectedId = nextId;
315
+ this.prewarmScheduler.schedule();
316
+ }
317
+
318
+ private prewarmSelected(): void {
319
+ // A debounced timer can outlive mode changes; never prewarm from modes
320
+ // where selectedId is not the navigation target.
321
+ if (this.mode !== "list" && this.mode !== "select") return;
322
+ const id = this.selectedId;
323
+ if (!id || id === this.prewarmedId) return;
324
+ const row = this.selectedRow();
325
+ if (!row) return;
326
+ // This session is open behind the board, and a second Pi must not write to its file.
327
+ if (this.deps.currentSessionFile && row.meta.sessionFile === this.deps.currentSessionFile) return;
328
+ // hostActive claims (starting/alive/stopping) already have a launch — mark
329
+ // them prewarmed without another service call; a pending claim is not a
330
+ // failure to retry on every debounce tick (issue #70 A14).
331
+ const decision = planPrewarm({ hostActive: row.hostActive, agentBusy: isAgentBusy(row) });
332
+ if (decision.action === "skip") return;
333
+ if (decision.action === "mark-only") {
334
+ this.prewarmedId = id;
335
+ return;
336
+ }
337
+ const res = this.deps.service.prewarmHost?.(id);
338
+ if (res?.ok) this.prewarmedId = id;
339
+ }
340
+
341
+ private notice(text: string, level: FlashLevel = "info"): void {
342
+ this.inputNotice = { text, color: NOTICE_COLOR[level], expiresAt: Date.now() + NOTICE_MS[level] };
343
+ }
344
+
345
+ /** `ms` keeps a "press again" notice from outlasting the time the second press still counts. */
346
+ private notifyInputState(text: string, color: InputNoticeColor, ms = NOTICE_MS.info): void {
347
+ this.inputNotice = { text, color, expiresAt: Date.now() + ms };
348
+ }
349
+
350
+ private currentInputNotice(): InputNotice | null {
351
+ if (this.inputNotice && this.inputNotice.expiresAt <= Date.now()) this.inputNotice = null;
352
+ return this.inputNotice;
353
+ }
354
+
355
+ private noticeText(): string | null {
356
+ const notice = this.currentInputNotice();
357
+ if (!notice) return null;
358
+ const icon = notice.color === "error" ? "×" : notice.color === "warning" ? "!" : "i";
359
+ const paint = (text: string) => this.theme.fg(notice.color as never, text);
360
+ return `${paint(this.theme.bold(icon))} ${paint(notice.text)}`;
361
+ }
362
+
363
+ // ---- input --------------------------------------------------------------
364
+
365
+ handleInput(data: string): void {
366
+ if (this.keybindings?.matches?.(data, "app.suspend")) return suspendToShell(this.tui);
367
+ if (!matchesKey(data, Key.ctrl("c"))) this.quitPresses.reset();
368
+ else if (this.quitPresses.press()) return this.done({ action: "quit" });
369
+ switch (this.mode) {
370
+ case "list":
371
+ this.handleListKey(data);
372
+ break;
373
+ case "select":
374
+ this.handleSelectKey(data);
375
+ break;
376
+ case "dispatch":
377
+ this.handleDispatchKey(data);
378
+ break;
379
+ case "filter":
380
+ this.handleTextMode(data, () => this.toListMode(), () => this.clearFilter(), { live: true });
381
+ break;
382
+ case "rename":
383
+ this.handleTextMode(data, () => this.submitRename(), () => this.toListMode());
384
+ break;
385
+ case "reply":
386
+ this.handleTextMode(data, () => this.submitReply(), () => (this.mode = "peek"));
387
+ break;
388
+ case "peek":
389
+ this.handlePeekKey(data);
390
+ break;
391
+ case "session":
392
+ this.handleSessionKey(data);
393
+ break;
394
+ case "evidence":
395
+ this.handleEvidenceKey(data);
396
+ break;
397
+ case "launch":
398
+ this.handleLaunchKey(data);
399
+ break;
400
+ case "confirm":
401
+ this.handleConfirmKey(data);
402
+ break;
403
+ case "help":
404
+ this.mode = this.helpReturnMode;
405
+ break;
406
+ case "ptyHelp":
407
+ this.mode = this.ptyHelpReturnMode;
408
+ break;
409
+ }
410
+ requestDashboardRender(this.tui);
411
+ }
412
+
413
+ private handleListKey(data: string): void {
414
+ if (!matchesKey(data, Key.ctrl("x"))) this.deleteArm = null;
415
+ if (this.handleRowKey(data)) return;
416
+ if (matchesKey(data, Key.right) || matchesKey(data, Key.left) || matchesKey(data, Key.enter)) return this.attachSelected();
417
+ if (matchesKey(data, Key.space)) return this.openPeek();
418
+ if (matchesKey(data, Key.ctrl("f"))) return this.startFilter();
419
+ if (data === "?") return this.openHelp("list");
420
+ if (data === "!") return this.openPtyHelp("list");
421
+ if (matchesKey(data, Key.ctrl("r"))) return this.startRename();
422
+ if (matchesKey(data, Key.ctrl("t"))) return this.togglePin();
423
+ if (matchesKey(data, Key.ctrl("x"))) return this.handleDeleteKey();
424
+ if (matchesKey(data, Key.ctrl("s"))) return this.toggleGrouping();
425
+ if (matchesKey(data, Key.ctrl("g"))) return void this.openExternalEditor();
426
+ if (matchesKey(data, Key.shift("up"))) return this.moveRow(-1);
427
+ if (matchesKey(data, Key.shift("down"))) return this.moveRow(1);
428
+ if (matchesKey(data, Key.ctrl("a"))) return this.startSelectionMode();
429
+ if (matchesKey(data, Key.ctrl("o"))) return this.openSessionView();
430
+ if (matchesKey(data, Key.ctrl("e"))) return this.openEvidenceView();
431
+ if (matchesKey(data, Key.ctrl("d"))) return this.confirmDone();
432
+ if (matchesKey(data, Key.ctrl("b"))) return this.toggleHold();
433
+ if (matchesKey(data, Key.ctrl("k"))) return this.confirmDeleteState();
434
+ for (let n = 1; n <= 9; n++) {
435
+ if (matchesKey(data, Key.alt(String(n) as never))) return this.attachInFolder(n - 1);
436
+ }
437
+ if (matchesKey(data, Key.escape)) return this.done({ action: "exit" });
438
+ if (matchesKey(data, Key.ctrl("c"))) return this.notifyInputState("Press ctrl+c again to quit Pi", "warning", QUIT_PRESS_MS);
439
+ if (isPrintable(data) || isBracketedPaste(data)) {
440
+ this.mode = "dispatch";
441
+ this.handleEditorInput(data);
442
+ }
443
+ }
444
+
445
+ /** The keys that move between rows, in the list and while typing a task. Says if it was one. */
446
+ private handleRowKey(data: string): boolean {
447
+ const rows = this.orderedIds.length;
448
+ const page = Math.max(1, (this.tui.terminal?.rows ?? 24) - 8);
449
+ const at = this.selectedId ? Math.max(0, this.orderedIds.indexOf(this.selectedId)) : 0;
450
+ if (matchesKey(data, Key.up)) this.moveSelection(-1);
451
+ else if (matchesKey(data, Key.down)) this.moveSelection(1);
452
+ else if (matchesKey(data, Key.pageUp)) this.selectIndex(Math.max(0, at - page));
453
+ else if (matchesKey(data, Key.pageDown)) this.selectIndex(Math.min(rows - 1, at + page));
454
+ else if (matchesKey(data, Key.home)) this.selectIndex(0);
455
+ else if (matchesKey(data, Key.end)) this.selectIndex(rows - 1);
456
+ else if (matchesKey(data, Key.alt("up"))) this.jumpGroup(-1);
457
+ else if (matchesKey(data, Key.alt("down"))) this.jumpGroup(1);
458
+ else return false;
459
+ return true;
460
+ }
461
+
462
+ private selectIndex(index: number): void {
463
+ const id = this.orderedIds[index];
464
+ if (!id || id === this.selectedId) return;
465
+ this.selectedId = id;
466
+ this.prewarmScheduler.schedule();
467
+ }
468
+
469
+ private toggleGrouping(): void {
470
+ this.groupBy = this.groupBy === "state" ? "directory" : "state";
471
+ try {
472
+ writeConfig({ groupBy: this.groupBy });
473
+ } catch {
474
+ /* best effort: it still applies until the board closes */
475
+ }
476
+ this.refresh();
477
+ }
478
+
479
+ /** Swaps it with the row next to it, within the same group. */
480
+ private moveRow(direction: 1 | -1): void {
481
+ const id = this.selectedId;
482
+ if (!id) return;
483
+ const neighbor = this.orderedIds[this.orderedIds.indexOf(id) + direction];
484
+ if (!neighbor || this.neighborsOf.get(neighbor) !== this.neighborsOf.get(id)) return;
485
+ const group = this.orderedIds.filter((other) => this.neighborsOf.get(other) === this.neighborsOf.get(id));
486
+ const at = group.indexOf(id);
487
+ [group[at], group[at + direction]] = [group[at + direction]!, group[at]!];
488
+ this.deps.service.setRowOrder?.(group);
489
+ this.refresh();
490
+ }
491
+
492
+ private async openExternalEditor(): Promise<void> {
493
+ const terminal = this.tui as unknown as { stop?: () => void; start?: () => void };
494
+ terminal.stop?.();
495
+ let edited: string | undefined;
496
+ try {
497
+ edited = await editExternally(this.input);
498
+ } finally {
499
+ terminal.start?.();
500
+ this.tui.requestRender(true);
501
+ }
502
+ if (edited === undefined) {
503
+ this.notifyInputState("Set $VISUAL or $EDITOR to write the task in your editor", "warning");
504
+ return;
505
+ }
506
+ this.setInput(edited);
507
+ this.mode = edited ? "dispatch" : "list";
508
+ }
509
+
510
+ /** To the first row of the next group, or of this one when going back. */
511
+ private jumpGroup(direction: 1 | -1): void {
512
+ const stateAt = (index: number) => this.groupOf.get(this.orderedIds[index]);
513
+ let index = this.selectedId ? this.orderedIds.indexOf(this.selectedId) : 0;
514
+ const start = stateAt(index);
515
+ if (direction === 1) {
516
+ while (index < this.orderedIds.length && stateAt(index) === start) index++;
517
+ if (index < this.orderedIds.length) this.selectIndex(index);
518
+ return;
519
+ }
520
+ if (index > 0 && stateAt(index - 1) !== start) index--;
521
+ const group = stateAt(index);
522
+ while (index > 0 && stateAt(index - 1) === group) index--;
523
+ this.selectIndex(index);
524
+ }
525
+
526
+ /** Counts the rows in the selected session's folder. */
527
+ private attachInFolder(index: number): void {
528
+ const folder = this.folderOf.get(this.selectedId ?? "");
529
+ const id = this.orderedIds.filter((other) => this.folderOf.get(other) === folder)[index];
530
+ if (!id) return;
531
+ this.selectIndex(this.orderedIds.indexOf(id));
532
+ this.attachSelected();
533
+ }
534
+
535
+ private handleDispatchKey(data: string): void {
536
+ // The menu has its own use for Enter, Tab, Esc and the arrows.
537
+ if (this.editor.isShowingAutocomplete() && !matchesKey(data, Key.ctrl("c"))) {
538
+ this.handleEditorInput(data);
539
+ if (!this.input && this.mode === "dispatch") this.mode = "list";
540
+ return;
541
+ }
542
+ if (matchesKey(data, Key.ctrl("n"))) return this.openLaunchDialog();
543
+ if (matchesKey(data, Key.ctrl("g"))) return void this.openExternalEditor();
544
+ if (matchesKey(data, Key.ctrl("enter"))) return this.startNow(true);
545
+ if (matchesKey(data, Key.enter)) return this.startNow();
546
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
547
+ this.setInput("");
548
+ this.mode = "list";
549
+ return;
550
+ }
551
+ // With several lines these move the cursor.
552
+ if (!this.input.includes("\n") && this.handleRowKey(data)) return;
553
+ this.handleEditorInput(data);
554
+ if (!this.input) this.mode = "list";
555
+ }
556
+
557
+ private handleSelectKey(data: string): void {
558
+ if (matchesKey(data, Key.up) || data === "k") return void this.moveSelection(-1);
559
+ if (matchesKey(data, Key.down) || data === "j") return void this.moveSelection(1);
560
+ if (matchesKey(data, Key.space)) {
561
+ this.toggleSelectedCurrent();
562
+ return;
563
+ }
564
+ if (data === "a") {
565
+ this.selectAllVisible();
566
+ this.notifyInputState(`SELECT mode — ${this.selectionCount()} selected`, "accent");
567
+ return;
568
+ }
569
+ if (data === "d") return this.confirmDoneSelection();
570
+ if (matchesKey(data, Key.ctrl("x"))) return this.confirmDeleteSelection();
571
+ if (data === "u") {
572
+ this.selectedIds.clear();
573
+ this.notifyInputState("SELECT mode — selection cleared", "accent");
574
+ return;
575
+ }
576
+ if (data === "m" || matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
577
+ this.exitSelectionMode(true);
578
+ return;
579
+ }
580
+ if (data === "?") return this.openHelp("select");
581
+ if (data === "!") return this.openPtyHelp("select");
582
+ }
583
+
584
+ private handlePeekKey(data: string): void {
585
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.space)) return void (this.mode = "list");
586
+ if (matchesKey(data, Key.right) || matchesKey(data, Key.left)) return this.attachPeek();
587
+ if (matchesKey(data, Key.up)) return void this.peekStep(-1);
588
+ if (matchesKey(data, Key.down)) return void this.peekStep(1);
589
+ if (matchesKey(data, Key.enter)) return this.startReply();
590
+ if (matchesKey(data, Key.ctrl("o"))) return this.openSessionView();
591
+ if (matchesKey(data, Key.ctrl("e"))) return this.openEvidenceView();
592
+ if (matchesKey(data, Key.ctrl("d"))) return this.confirmDone();
593
+ if (matchesKey(data, Key.ctrl("b"))) return this.toggleHold();
594
+ if (data === "!") return this.openPtyHelp("peek");
595
+ if (isPrintable(data) || isBracketedPaste(data)) {
596
+ this.startReply();
597
+ this.handleEditorInput(data);
598
+ }
599
+ }
600
+
601
+ private handleSessionKey(data: string): void {
602
+ if (data === "!") return this.openPtyHelp("session");
603
+ const termRows = this.tui.terminal?.rows ?? 24;
604
+ const page = Math.max(1, termRows - 8);
605
+ if (matchesKey(data, Key.left) || matchesKey(data, Key.escape) || data === "<") {
606
+ const row = this.selectedRow();
607
+ if (row) void this.deps.service.markVisited?.(row.meta.id)?.catch(() => {});
608
+ this.mode = "list";
609
+ return;
610
+ }
611
+ if (matchesKey(data, Key.up) || data === "k") {
612
+ this.sessionScrollTop = Math.max(0, this.sessionScrollTop - 1);
613
+ return;
614
+ }
615
+ if (matchesKey(data, Key.down) || data === "j") {
616
+ this.sessionScrollTop += 1;
617
+ return;
618
+ }
619
+ if (matchesKey(data, Key.pageUp)) {
620
+ this.sessionScrollTop = Math.max(0, this.sessionScrollTop - page);
621
+ return;
622
+ }
623
+ if (matchesKey(data, Key.pageDown)) {
624
+ this.sessionScrollTop += page;
625
+ return;
626
+ }
627
+ if (matchesKey(data, Key.space)) return this.openPeek();
628
+ if (data === "e") return this.openEvidenceView();
629
+ if (data === "r") {
630
+ this.startReply();
631
+ return;
632
+ }
633
+ if (data === "d") return this.confirmDone();
634
+ if (data === "h") return this.toggleHold();
635
+ if (data === "a" || matchesKey(data, Key.enter)) return this.attachSelected();
636
+ }
637
+
638
+ private handleEvidenceKey(data: string): void {
639
+ const termRows = this.tui.terminal?.rows ?? 24;
640
+ const page = Math.max(1, termRows - 8);
641
+ if (matchesKey(data, Key.left) || matchesKey(data, Key.escape) || data === "<") {
642
+ this.mode = "list";
643
+ return;
644
+ }
645
+ if (matchesKey(data, Key.up) || data === "k") {
646
+ this.evidenceScrollTop = Math.max(0, this.evidenceScrollTop - 1);
647
+ return;
648
+ }
649
+ if (matchesKey(data, Key.down) || data === "j") {
650
+ this.evidenceScrollTop += 1;
651
+ return;
652
+ }
653
+ if (matchesKey(data, Key.pageUp)) {
654
+ this.evidenceScrollTop = Math.max(0, this.evidenceScrollTop - page);
655
+ return;
656
+ }
657
+ if (matchesKey(data, Key.pageDown)) {
658
+ this.evidenceScrollTop += page;
659
+ return;
660
+ }
661
+ if (data === "r") return this.startReply();
662
+ if (data === "v") return this.openSessionView();
663
+ if (data === "a" || matchesKey(data, Key.enter) || matchesKey(data, Key.right) || data === "l") return this.attachSelected();
664
+ if (data === "x") return this.confirmClearDiagnostics();
665
+ }
666
+
667
+ private handleConfirmKey(data: string): void {
668
+ if (data === "y" || data === "Y" || matchesKey(data, Key.enter)) {
669
+ const action = this.pending?.onYes;
670
+ const returnMode = this.pending?.returnMode ?? "list";
671
+ this.pending = null;
672
+ this.mode = returnMode;
673
+ try {
674
+ action?.();
675
+ } catch (err) {
676
+ this.notice(err instanceof Error ? err.message : String(err), "error");
677
+ }
678
+ return;
679
+ }
680
+ // any other key cancels
681
+ const returnMode = this.pending?.returnMode ?? "list";
682
+ this.pending = null;
683
+ this.mode = returnMode;
684
+ }
685
+
686
+ private handleLaunchKey(data: string): void {
687
+ const launch = this.launch;
688
+ if (!launch) {
689
+ this.mode = "list";
690
+ return;
691
+ }
692
+ if (launch.picker) {
693
+ this.handleLaunchPickerKey(data, launch);
694
+ return;
695
+ }
696
+ if (matchesKey(data, Key.up)) {
697
+ launch.fieldIndex = launch.fieldIndex === 0 ? 4 : launch.fieldIndex - 1;
698
+ return;
699
+ }
700
+ if (matchesKey(data, Key.down)) {
701
+ launch.fieldIndex = launch.fieldIndex === 4 ? 0 : launch.fieldIndex + 1;
702
+ return;
703
+ }
704
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
705
+ this.closeLaunchDialog();
706
+ return;
707
+ }
708
+ if (matchesKey(data, Key.enter)) {
709
+ this.activateLaunchField();
710
+ return;
711
+ }
712
+ if (launch.fieldIndex === 1 || launch.fieldIndex === 2) {
713
+ const seeded = this.applyLaunchQueryInput("", data);
714
+ if (seeded !== null) {
715
+ this.openLaunchPicker(launch.fieldIndex === 1 ? "cwd" : "model", seeded);
716
+ }
717
+ }
718
+ }
719
+
720
+ private handleLaunchPickerKey(data: string, launch: LaunchState): void {
721
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
722
+ launch.picker = null;
723
+ return;
724
+ }
725
+ if (matchesKey(data, Key.up)) {
726
+ if (launch.picker === "cwd") launch.cwdSuggestionIndex = Math.max(0, launch.cwdSuggestionIndex - 1);
727
+ else if (launch.picker === "model") launch.modelIndex = Math.max(0, launch.modelIndex - 1);
728
+ else launch.thinkingIndex = Math.max(0, launch.thinkingIndex - 1);
729
+ return;
730
+ }
731
+ if (matchesKey(data, Key.down)) {
732
+ if (launch.picker === "cwd") launch.cwdSuggestionIndex = Math.min(Math.max(0, launch.cwdSuggestions.length - 1), launch.cwdSuggestionIndex + 1);
733
+ else if (launch.picker === "model") launch.modelIndex = Math.min(Math.max(0, launch.modelFiltered.length - 1), launch.modelIndex + 1);
734
+ else launch.thinkingIndex = Math.min(Math.max(0, launch.thinkingOptions.length - 1), launch.thinkingIndex + 1);
735
+ return;
736
+ }
737
+ if (matchesKey(data, Key.enter)) {
738
+ if (launch.picker === "cwd") {
739
+ const selected = launch.cwdSuggestions[launch.cwdSuggestionIndex] ?? resolveDirectoryValue(launch.cwdQuery, launch.cwd);
740
+ if (!selected) {
741
+ this.notice("Select an existing folder", "warn");
742
+ return;
743
+ }
744
+ this.selectLaunchCwd(selected);
745
+ if (this.launch) this.launch.picker = null;
746
+ return;
747
+ }
748
+ if (launch.picker === "model") {
749
+ const selected = launch.modelFiltered[launch.modelIndex];
750
+ if (!selected) {
751
+ this.notice("Select a model", "warn");
752
+ return;
753
+ }
754
+ this.selectLaunchModel(selected);
755
+ launch.picker = null;
756
+ return;
757
+ }
758
+ const selectedThinking = launch.thinkingOptions[launch.thinkingIndex];
759
+ if (selectedThinking) launch.thinking = selectedThinking;
760
+ launch.picker = null;
761
+ return;
762
+ }
763
+ if (launch.picker === "cwd" && matchesKey(data, Key.tab)) {
764
+ if (launch.cwdSuggestions.length > 0) {
765
+ if (launch.cwdPickerMode === "browse") {
766
+ const completion = browseTabCompletion(launch.cwdQuery, launch.cwdSuggestions, launch.cwdSuggestionIndex);
767
+ if (completion) {
768
+ launch.cwdQuery = completion.query;
769
+ const state = nextCwdPickerState(launch.cwdQuery, launch.cwdRanked, launch.cwd);
770
+ launch.cwdPickerMode = state.mode;
771
+ launch.cwdSuggestions = state.suggestions;
772
+ launch.cwdSuggestionIndex = Math.max(0, state.suggestions.indexOf(completion.completed));
773
+ }
774
+ } else {
775
+ const completed = launch.cwdSuggestions[launch.cwdSuggestionIndex] ?? launch.cwdSuggestions[0];
776
+ launch.cwdQuery = completed;
777
+ const state = nextCwdPickerState(launch.cwdQuery, launch.cwdRanked, launch.cwd);
778
+ launch.cwdPickerMode = state.mode;
779
+ launch.cwdSuggestions = state.suggestions;
780
+ launch.cwdSuggestionIndex = Math.max(0, state.suggestions.indexOf(completed));
781
+ }
782
+ }
783
+ return;
784
+ }
785
+ if (launch.picker === "cwd") {
786
+ const next = this.applyLaunchQueryInput(launch.cwdQuery, data);
787
+ if (next !== null) {
788
+ launch.cwdQuery = next;
789
+ const state = nextCwdPickerState(next, launch.cwdRanked, launch.cwd);
790
+ launch.cwdPickerMode = state.mode;
791
+ launch.cwdSuggestions = state.suggestions;
792
+ launch.cwdSuggestionIndex = 0;
793
+ }
794
+ return;
795
+ }
796
+ if (launch.picker === "model") {
797
+ const next = this.applyLaunchQueryInput(launch.modelQuery, data);
798
+ if (next !== null) {
799
+ launch.modelQuery = next;
800
+ launch.modelFiltered = filterLaunchChoices(launch.choices, next);
801
+ launch.modelIndex = 0;
802
+ }
803
+ }
804
+ }
805
+
806
+ private applyLaunchQueryInput(current: string, data: string): string | null {
807
+ if (matchesKey(data, Key.backspace)) return current.slice(0, -1);
808
+ if (isBracketedPaste(data)) return `${current}${stripBracketedPaste(data)}`;
809
+ if (isPrintable(data)) return `${current}${data}`;
810
+ return null;
811
+ }
812
+
813
+ private activateLaunchField(): void {
814
+ if (!this.launch) return;
815
+ if (this.launch.fieldIndex === 0) {
816
+ this.confirmLaunch();
817
+ return;
818
+ }
819
+ if (this.launch.fieldIndex === 1) return this.openLaunchPicker("cwd");
820
+ if (this.launch.fieldIndex === 2) return this.openLaunchPicker("model", "");
821
+ if (this.launch.fieldIndex === 3) {
822
+ if (this.launch.thinkingOptions.length <= 1) return;
823
+ this.openLaunchPicker("thinking");
824
+ return;
825
+ }
826
+ if (this.launch.fieldIndex === 4) {
827
+ this.launch.action = this.launch.action === "attach" ? "background" : "attach";
828
+ return;
829
+ }
830
+ }
831
+
832
+ /** The selected session's folder, or this one with nothing selected or if that folder is gone. */
833
+ private newTaskCwd(): string {
834
+ return resolveDirectoryValue(this.selectedRow()?.meta.cwd ?? "", this.deps.defaultCwd) ?? this.deps.defaultCwd;
835
+ }
836
+
837
+ /** In the folder on the input's title, with this session's model. Ctrl+N is there for anything else. */
838
+ private startNow(attach = false): void {
839
+ const text = this.input.trim();
840
+ if (runsInPi(text, this.deps.commands ?? [])) {
841
+ this.setInput("");
842
+ return this.done({ action: "command", text });
843
+ }
844
+ const cwd = this.newTaskCwd();
845
+ const { model, thinking } = this.buildLaunchState(cwd, this.deps.currentModel, this.deps.currentThinkingLevel);
846
+ this.submitDispatch({ cwd, model: model ? canonicalModelRef(model) : null, thinkingLevel: thinking, attach });
847
+ }
848
+
849
+ private openLaunchDialog(): void {
850
+ const prompt = this.input.trim();
851
+ if (!prompt) return this.toListMode();
852
+ const defaults = this.launchDefaults();
853
+ try {
854
+ ensureCwdStatsSeeded(this.deps.root);
855
+ } catch {
856
+ /* best effort: favorites degrade to browse mode */
857
+ }
858
+ this.launch = this.buildLaunchState(defaults.cwd, defaults.model, defaults.thinking);
859
+ this.mode = "launch";
860
+ this.inputNotice = null;
861
+ }
862
+
863
+ private launchDefaults(): { cwd: string; model: LaunchModel | null; thinking: ThinkingLevel } {
864
+ const saved = this.lastLaunchPrefs;
865
+ const cwd = this.selectedRow() ? this.newTaskCwd() : (resolveDirectoryValue(saved?.cwd ?? "", this.deps.defaultCwd) ?? this.deps.defaultCwd);
866
+ const model = saved?.model ? findLaunchModelByRef(this.deps.availableModels, saved.model) ?? this.deps.currentModel : this.deps.currentModel;
867
+ const thinking = saved?.thinkingLevel ?? this.deps.currentThinkingLevel;
868
+ return { cwd, model, thinking };
869
+ }
870
+
871
+ private closeLaunchDialog(): void {
872
+ this.launch = null;
873
+ this.mode = "list";
874
+ }
875
+
876
+ private confirmLaunch(): void {
877
+ if (!this.launch) return;
878
+ this.submitDispatch({
879
+ cwd: this.launch.cwd,
880
+ model: this.launch.model ? canonicalModelRef(this.launch.model) : null,
881
+ thinkingLevel: this.launch.thinking,
882
+ attach: this.launch.action === "attach",
883
+ });
884
+ }
885
+
886
+ private buildLaunchState(cwd: string, preferredModel: LaunchModel | null, preferredThinking: ThinkingLevel): LaunchState {
887
+ const context = resolveLaunchContext(cwd, this.deps.availableModels, preferredModel, preferredThinking) as {
888
+ choices: LaunchChoice[];
889
+ selectedModel: LaunchModel | null;
890
+ thinking: ThinkingLevel;
891
+ thinkingOptions: ThinkingLevel[];
892
+ scopeSource: "scoped" | "all";
893
+ };
894
+ const modelFiltered = filterLaunchChoices(context.choices, "");
895
+ const modelIndex = Math.max(0, modelFiltered.findIndex((choice) => sameLaunchModel(choice.model, context.selectedModel)));
896
+ const thinkingOptions = supportedThinkingLevels(context.selectedModel) as ThinkingLevel[];
897
+ const thinking = clampThinkingLevel(context.selectedModel, context.thinking, context.thinking);
898
+ const thinkingIndex = Math.max(0, thinkingOptions.indexOf(thinking));
899
+ return {
900
+ fieldIndex: 0,
901
+ picker: null,
902
+ action: "background",
903
+ cwd,
904
+ cwdQuery: "",
905
+ cwdSuggestions: [],
906
+ cwdSuggestionIndex: 0,
907
+ cwdRanked: [],
908
+ cwdPickerMode: "browse",
909
+ choices: context.choices,
910
+ scopeSource: context.scopeSource,
911
+ model: context.selectedModel,
912
+ modelQuery: "",
913
+ modelFiltered,
914
+ modelIndex,
915
+ thinking,
916
+ thinkingOptions,
917
+ thinkingIndex,
918
+ };
919
+ }
920
+
921
+ private openLaunchPicker(picker: Exclude<LaunchPicker, null>, seed?: string): void {
922
+ const launch = this.launch;
923
+ if (!launch) return;
924
+ launch.picker = picker;
925
+ if (picker === "cwd") {
926
+ try {
927
+ ensureCwdStatsSeeded(this.deps.root);
928
+ launch.cwdRanked = existingCwdCandidates(rankedCwdCandidates(this.deps.root));
929
+ } catch {
930
+ launch.cwdRanked = [];
931
+ }
932
+ launch.cwdQuery = seed ?? "";
933
+ const state = nextCwdPickerState(launch.cwdQuery, launch.cwdRanked, launch.cwd);
934
+ launch.cwdPickerMode = state.mode;
935
+ launch.cwdSuggestions = state.suggestions;
936
+ launch.cwdSuggestionIndex = 0;
937
+ return;
938
+ }
939
+ if (picker === "model") {
940
+ launch.modelQuery = seed ?? "";
941
+ launch.modelFiltered = filterLaunchChoices(launch.choices, launch.modelQuery);
942
+ const selected = launch.modelFiltered.findIndex((choice) => sameLaunchModel(choice.model, launch.model));
943
+ launch.modelIndex = selected >= 0 ? selected : 0;
944
+ return;
945
+ }
946
+ launch.thinkingOptions = supportedThinkingLevels(launch.model) as ThinkingLevel[];
947
+ launch.thinkingIndex = Math.max(0, launch.thinkingOptions.indexOf(launch.thinking));
948
+ }
949
+
950
+ private selectLaunchCwd(cwd: string): void {
951
+ if (!this.launch) return;
952
+ const next = this.buildLaunchState(cwd, this.launch.model, this.launch.thinking);
953
+ next.action = this.launch.action;
954
+ // Back on the top row, where Enter starts the session. On this one it opens the picker again.
955
+ next.fieldIndex = 0;
956
+ next.cwdQuery = cwd;
957
+ next.cwdSuggestions = listDirectorySuggestions(cwd, cwd);
958
+ this.launch = next;
959
+ }
960
+
961
+ private selectLaunchModel(choice: LaunchChoice): void {
962
+ if (!this.launch) return;
963
+ this.launch.model = choice.model;
964
+ this.launch.modelQuery = "";
965
+ this.launch.modelFiltered = filterLaunchChoices(this.launch.choices, "");
966
+ this.launch.modelIndex = Math.max(0, this.launch.modelFiltered.findIndex((entry) => sameLaunchModel(entry.model, choice.model)));
967
+ this.launch.thinkingOptions = supportedThinkingLevels(choice.model) as ThinkingLevel[];
968
+ this.launch.thinking = clampThinkingLevel(choice.model, choice.thinkingLevel ?? this.launch.thinking, this.launch.thinking);
969
+ this.launch.thinkingIndex = Math.max(0, this.launch.thinkingOptions.indexOf(this.launch.thinking));
970
+ }
971
+
972
+ /** Shared text-editing for input modes. */
973
+ private handleTextMode(data: string, onSubmit: () => void, onCancel: () => void, opts: { live?: boolean } = {}): void {
974
+ void opts.live; // live filtering is driven by editor.onChange.
975
+ if (matchesKey(data, Key.enter)) return onSubmit();
976
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) return onCancel();
977
+ this.handleEditorInput(data);
978
+ }
979
+
980
+ /** Only for a task that starts with `/`: the same editor takes filters, names and replies. */
981
+ private commandMenu(): CombinedAutocompleteProvider {
982
+ const menu = new CombinedAutocompleteProvider(this.deps.commands ?? [], this.deps.defaultCwd);
983
+ const suggest = menu.getSuggestions.bind(menu);
984
+ menu.getSuggestions = async (lines, line, col, options) => {
985
+ if (this.mode !== "dispatch" || line !== 0 || !lines[0]?.startsWith("/")) return null;
986
+ return suggest(lines, line, col, options);
987
+ };
988
+ return menu;
989
+ }
990
+
991
+ private handleEditorInput(data: string): void {
992
+ this.editor.handleInput(data);
993
+ this.input = this.editor.getExpandedText();
994
+ }
995
+
996
+ private setInput(text: string): void {
997
+ this.input = text;
998
+ this.editor.setText(text);
999
+ }
1000
+
1001
+ private submitEditor(textOverride?: string): void {
1002
+ if (textOverride !== undefined) this.input = textOverride;
1003
+ switch (this.mode) {
1004
+ case "filter":
1005
+ return this.toListMode();
1006
+ case "rename":
1007
+ return this.submitRename();
1008
+ case "reply":
1009
+ void this.submitReply();
1010
+ return;
1011
+ case "list":
1012
+ case "dispatch":
1013
+ return this.input.trim() ? this.startNow() : this.attachSelected();
1014
+ }
1015
+ }
1016
+
1017
+ // ---- actions ------------------------------------------------------------
1018
+
1019
+ private toListMode(): void {
1020
+ this.mode = "list";
1021
+ this.setInput("");
1022
+ }
1023
+
1024
+ private startFilter(): void {
1025
+ this.setInput(this.filterQuery);
1026
+ this.mode = "filter";
1027
+ this.notifyInputState("FILTER mode — type query; Esc clears", "warning");
1028
+ }
1029
+
1030
+ private clearFilter(): void {
1031
+ this.setInput("");
1032
+ this.filterQuery = "";
1033
+ this.refresh();
1034
+ this.mode = "list";
1035
+ this.notifyInputState("Filter cleared", "muted");
1036
+ }
1037
+
1038
+ private submitDispatch(launchOpts?: { cwd?: string; model?: string | null; thinkingLevel?: ThinkingLevel | null; attach?: boolean }): void {
1039
+ const text = this.input.trim();
1040
+ if (!text) return this.toListMode();
1041
+ const launchCwd = launchOpts?.cwd ?? this.deps.defaultCwd;
1042
+ const launchModel = launchOpts?.model ?? (this.launch?.model ? canonicalModelRef(this.launch.model) : null);
1043
+ const launchThinking = launchOpts?.thinkingLevel ?? this.launch?.thinking ?? this.deps.currentThinkingLevel;
1044
+ // Clear synchronously, BEFORE the async dispatch: closes the double-submit
1045
+ // window — a second Enter during the coordinator round-trip (mark_queued
1046
+ // lands before the runner spawns, issue #91) must not re-dispatch.
1047
+ this.setInput("");
1048
+ this.launch = null;
1049
+ this.mode = "list";
1050
+ this.inputNotice = null;
1051
+ this.refresh();
1052
+ // dispatch is async (mark_queued routes through the view-state coordinator
1053
+ // before the runner spawns, issue #91); notices/attach land when it settles.
1054
+ void Promise.resolve(
1055
+ this.deps.service.dispatch(text, {
1056
+ cwd: launchCwd,
1057
+ model: launchModel,
1058
+ thinkingLevel: launchThinking,
1059
+ }),
1060
+ ).then((res) => {
1061
+ if (!res.ok) {
1062
+ this.notice(res.error ?? "Dispatch failed", "error");
1063
+ this.refresh();
1064
+ return;
1065
+ }
1066
+ this.lastLaunchPrefs = { ...this.deps.service.getLaunchPrefs?.(), cwd: launchCwd, model: launchModel, thinkingLevel: launchThinking };
1067
+ try {
1068
+ recordCwdLaunch(this.deps.root, launchCwd);
1069
+ } catch {
1070
+ /* best effort: stats must never block dispatch */
1071
+ }
1072
+ try {
1073
+ this.deps.service.saveLaunchPrefs?.(this.lastLaunchPrefs);
1074
+ } catch {
1075
+ /* best effort */
1076
+ }
1077
+ this.selectedId = res.viewId ?? this.selectedId;
1078
+ if (launchOpts?.attach && res.hostMode === "pty" && res.viewId) {
1079
+ this.done({ action: "attach", viewId: res.viewId, stopFirst: false });
1080
+ return;
1081
+ }
1082
+ if (res.hostMode === "json-runner") {
1083
+ this.notice(launchOpts?.attach ? `Start & attach needs PTY; launched in background: ${res.fallbackReason ?? "PTY unavailable"}` : `Dispatched with non-live fallback: ${res.fallbackReason ?? "PTY unavailable"}`, "warn");
1084
+ } else {
1085
+ this.notice(`Dispatched: ${truncate(text, 40)}`, "info");
1086
+ }
1087
+ this.refresh();
1088
+ }).catch(() => {
1089
+ // dispatch() is not expected to reject (its failures surface as
1090
+ // {ok:false}); this guards fs-class throws from escaping as unhandled
1091
+ // rejections (issue #91 hygiene).
1092
+ this.notice("Dispatch failed", "error");
1093
+ this.refresh();
1094
+ });
1095
+ }
1096
+
1097
+ private async submitReply(): Promise<void> {
1098
+ const text = this.input.trim();
1099
+ const row = this.selectedRow();
1100
+ if (!text || !row) {
1101
+ this.mode = "peek";
1102
+ this.setInput("");
1103
+ return;
1104
+ }
1105
+ // reply() is async since issue #70 A13 (ack-gated host delivery); the
1106
+ // result (sent vs queued) only shapes the notice.
1107
+ const res = await this.deps.service.reply(row.meta.id, text);
1108
+ const notice = replyNotice(res);
1109
+ this.notice(notice.text, notice.level);
1110
+ this.setInput("");
1111
+ this.mode = "peek";
1112
+ this.inputNotice = null;
1113
+ this.refresh();
1114
+ }
1115
+
1116
+ private submitRename(): void {
1117
+ const name = this.input.trim();
1118
+ if (name && this.selectedId) {
1119
+ const res = this.deps.service.rename(this.selectedId, name);
1120
+ if (!res.ok) this.notice(res.error ?? "Rename failed", "error");
1121
+ }
1122
+ this.toListMode();
1123
+ this.refresh();
1124
+ }
1125
+
1126
+ private startRename(): void {
1127
+ const row = this.selectedRow();
1128
+ if (!row) return;
1129
+ this.setInput(row.meta.name);
1130
+ this.mode = "rename";
1131
+ this.notifyInputState("RENAME mode — Enter saves; Esc cancels", "accent");
1132
+ }
1133
+
1134
+ private togglePin(): void {
1135
+ const row = this.selectedRow();
1136
+ if (!row) return;
1137
+ this.deps.service.setPinned(row.meta.id, !row.meta.pinned);
1138
+ this.refresh();
1139
+ }
1140
+
1141
+ private stopSelected(): void {
1142
+ const row = this.selectedRow();
1143
+ if (!row) return;
1144
+ if (!row.alive && !row.hostAlive) return this.notice("No active run or host to stop", "warn");
1145
+ const res = this.deps.service.stop(row.meta.id);
1146
+ this.notice(res.ok ? "Stopping…" : (res.error ?? "Stop failed"), res.ok ? "info" : "warn");
1147
+ }
1148
+
1149
+ private confirmDone(): void {
1150
+ const row = this.selectedRow();
1151
+ if (!row) return;
1152
+ if (isAgentBusy(row)) return this.notice("Wait for the active run to finish before marking done", "warn");
1153
+ if (row.state?.semanticState === "completed") return this.notice("Already marked done", "info");
1154
+ this.pending = {
1155
+ prompt: `Mark "${row.meta.name}" as done? (y/N)`,
1156
+ onYes: () => {
1157
+ void this.markCompleted(row).then((res) => {
1158
+ if (!res.ok) this.notice(res.error ?? "Mark done failed", "error");
1159
+ else this.notice("Marked done", "info");
1160
+ this.refresh();
1161
+ });
1162
+ },
1163
+ };
1164
+ this.mode = "confirm";
1165
+ }
1166
+
1167
+ // On-hold toggle (issue #145): manual-only state, so the command travels as
1168
+ // dashboard-user; the coordinator's source guard rejects any other writer.
1169
+ private toggleHold(): void {
1170
+ const row = this.selectedRow();
1171
+ if (!row) return;
1172
+ const holding = row.state?.semanticState === "holding";
1173
+ if (!holding && isAgentBusy(row)) return this.notice("Wait for the active run to finish before placing on hold", "warn");
1174
+ const run = holding ? this.deps.service.clearHoldView(row.meta.id) : this.deps.service.holdView(row.meta.id);
1175
+ void Promise.resolve(run).then((res) => {
1176
+ if (!res.ok) this.notice(res.error ?? (holding ? "Resume failed" : "Hold failed"), "error");
1177
+ else this.notice(holding ? "Resumed — needs instructions" : "On hold", "info");
1178
+ this.refresh();
1179
+ });
1180
+ }
1181
+
1182
+ private confirmDoneSelection(): void {
1183
+ const rows = this.selectedBatchRows();
1184
+ if (rows.length === 0) return this.notice("Select one or more sessions first", "warn");
1185
+ const doneIds = rows.filter((row) => !isAgentBusy(row) && row.state?.semanticState !== "completed").map((row) => row.meta.id);
1186
+ const skipped = rows.length - doneIds.length;
1187
+ if (doneIds.length === 0) return this.notice("No inactive sessions in the selection can be moved to Done", "warn");
1188
+ this.pending = {
1189
+ prompt: `Mark ${doneIds.length} selected session${doneIds.length === 1 ? "" : "s"} as done?${skipped ? ` ${skipped} will be skipped.` : ""} (y/N)`,
1190
+ returnMode: "select",
1191
+ onYes: () => {
1192
+ void Promise.resolve(this.deps.service.markCompletedMany?.(doneIds) ?? { ok: true, completed: 0, skipped: doneIds.length, completedIds: [] }).then((res) => {
1193
+ if (!res.ok) this.notice("Mark done failed", "error");
1194
+ else {
1195
+ if (res.completedIds?.length) this.selectedId = res.completedIds[0];
1196
+ this.notice(`Moved ${res.completed} to Done${res.skipped ? ` · skipped ${res.skipped}` : ""}`, "info");
1197
+ }
1198
+ this.refresh();
1199
+ });
1200
+ },
1201
+ };
1202
+ this.mode = "confirm";
1203
+ }
1204
+
1205
+ // Routes through the View State Coordinator (issue #91): the service's
1206
+ // markCompleted submits a fenced state command. No compat fallback for stale
1207
+ // pre-coordinator service objects — the window is transient and self-heals
1208
+ // on the next dashboard reload.
1209
+ private markCompleted(row: Row): Promise<{ ok: boolean; error?: string }> {
1210
+ return this.deps.service.markCompleted(row.meta.id);
1211
+ }
1212
+
1213
+ private handleDeleteKey(): void {
1214
+ const row = this.selectedRow();
1215
+ if (!row) return;
1216
+ const now = Date.now();
1217
+ if (this.deleteArm !== null && this.deleteArm.id === row.meta.id && now - this.deleteArm.at <= DELETE_DOUBLE_PRESS_MS) {
1218
+ this.deleteArm = null;
1219
+ // archive is async (routes through the view-state coordinator, issue #91);
1220
+ // the notice + refresh land when the command settles.
1221
+ void Promise.resolve(this.deps.service.archive(row.meta.id)).then((res) => {
1222
+ if (!res.ok) this.notice(res.error ?? "Delete failed", "error");
1223
+ else this.notice(`Deleted "${row.meta.name}"`, "info");
1224
+ this.refresh();
1225
+ });
1226
+ return;
1227
+ }
1228
+ this.deleteArm = { id: row.meta.id, at: now };
1229
+ if (row.alive || row.hostAlive) {
1230
+ const res = this.deps.service.stop(row.meta.id);
1231
+ this.notifyInputState(res.ok ? `Stopping "${row.meta.name}". Press ctrl+x again to delete it` : (res.error ?? "Stop failed"), "warning", DELETE_DOUBLE_PRESS_MS);
1232
+ return;
1233
+ }
1234
+ this.notifyInputState(`Press ctrl+x again quickly to delete "${row.meta.name}"${isAgentBusy(row) ? " (stops the active run)" : ""}`, "warning", DELETE_DOUBLE_PRESS_MS);
1235
+ }
1236
+
1237
+ private confirmDeleteState(): void {
1238
+ const row = this.selectedRow();
1239
+ if (!row) return;
1240
+ const state = rowState(row);
1241
+ const matching = this.deps.service.rows().filter((r) => rowState(r) === state);
1242
+ const deletable = matching.filter((r) => !isAgentBusy(r)).length;
1243
+ const skipped = matching.length - deletable;
1244
+ if (deletable === 0) return this.notice(`No inactive ${GROUP_LABELS[state].toLowerCase()} sessions to delete`, "warn");
1245
+ this.pending = {
1246
+ prompt: `Delete ${deletable} ${GROUP_LABELS[state].toLowerCase()} session${deletable === 1 ? "" : "s"}?${skipped ? ` ${skipped} live skipped.` : ""} (y/N)`,
1247
+ onYes: () => {
1248
+ const res = this.deps.service.archiveByState(state);
1249
+ if (!res.ok) this.notice(res.error ?? "Delete failed", "error");
1250
+ else this.notice(`Deleted ${res.archived}${res.skipped ? ` · skipped ${res.skipped} live` : ""}`, "info");
1251
+ this.refresh();
1252
+ },
1253
+ };
1254
+ this.mode = "confirm";
1255
+ }
1256
+
1257
+ private confirmDeleteSelection(): void {
1258
+ const rows = this.selectedBatchRows();
1259
+ if (rows.length === 0) return this.notice("Select one or more Done sessions first", "warn");
1260
+ if (rows.some((row) => rowState(row) !== "completed")) return this.notice("Only Done sessions can be batch deleted", "warn");
1261
+ this.pending = {
1262
+ prompt: `Delete ${rows.length} done session${rows.length === 1 ? "" : "s"}? Session files are preserved. (y/N)`,
1263
+ returnMode: "select",
1264
+ onYes: () => {
1265
+ // archiveMany is async (coordinator-routed, issue #91).
1266
+ void Promise.resolve(this.deps.service.archiveMany?.(rows.map((row) => row.meta.id)) ?? { ok: true, archived: 0, skipped: rows.length }).then((res) => {
1267
+ if (!res.ok) this.notice("Delete failed", "error");
1268
+ else this.notice(`Deleted ${res.archived}${res.skipped ? ` · skipped ${res.skipped}` : ""}`, "info");
1269
+ this.exitSelectionMode(true);
1270
+ this.refresh();
1271
+ });
1272
+ },
1273
+ };
1274
+ this.mode = "confirm";
1275
+ }
1276
+
1277
+ private openPeek(): void {
1278
+ if (!this.selectedId) return;
1279
+ this.peekId = this.selectedId;
1280
+ this.mode = "peek";
1281
+ }
1282
+
1283
+ private startReply(): void {
1284
+ this.peekId = this.peekId ?? this.selectedId;
1285
+ this.setInput("");
1286
+ this.mode = "reply";
1287
+ }
1288
+
1289
+ private openSessionView(): void {
1290
+ if (!this.selectedId) return;
1291
+ void this.deps.service.markVisited?.(this.selectedId)?.catch(() => {});
1292
+ this.sessionScrollTop = 0;
1293
+ this.mode = "session";
1294
+ }
1295
+
1296
+ private openEvidenceView(): void {
1297
+ if (!this.selectedId) return;
1298
+ this.evidenceScrollTop = 0;
1299
+ this.mode = "evidence";
1300
+ }
1301
+
1302
+ private confirmClearDiagnostics(): void {
1303
+ const row = this.selectedRow();
1304
+ if (!row) return;
1305
+ this.pending = {
1306
+ prompt: `Clear diagnostics for "${row.meta.name}"? Evidence is preserved. (y/N)`,
1307
+ returnMode: "evidence",
1308
+ onYes: () => {
1309
+ const res = this.deps.service.clearDiagnostics?.(row.meta.id) as { ok?: boolean; error?: string } | undefined;
1310
+ if (!res?.ok) this.notice(res?.error ?? "Clear diagnostics failed", "error");
1311
+ else this.notice("Diagnostics cleared", "info");
1312
+ this.refresh();
1313
+ },
1314
+ };
1315
+ this.mode = "confirm";
1316
+ }
1317
+
1318
+ private peekStep(delta: number): void {
1319
+ if (!this.peekId) return;
1320
+ const idx = this.orderedIds.indexOf(this.peekId);
1321
+ if (idx < 0) return;
1322
+ const len = this.orderedIds.length;
1323
+ // Wrap around both ends, same as moveSelection().
1324
+ const next = ((idx + delta) % len + len) % len;
1325
+ this.peekId = this.orderedIds[next];
1326
+ this.selectedId = this.peekId;
1327
+ }
1328
+
1329
+ private attachSelected(): void {
1330
+ const row = this.selectedRow();
1331
+ if (!row) return;
1332
+ this.requestAttach(row);
1333
+ }
1334
+
1335
+ private attachPeek(): void {
1336
+ const row = this.selectedRow();
1337
+ if (row) this.requestAttach(row);
1338
+ }
1339
+
1340
+ private requestAttach(row: Row): void {
1341
+ // hostActive rows (starting/alive/stopping) attach directly — the async
1342
+ // resolver owns the starting/stopping wait, so no interrupt confirm even
1343
+ // when the row is busy (issue #70 A14).
1344
+ const plan = planDashboardAttach({ hostActive: row.hostActive, agentBusy: isAgentBusy(row) });
1345
+ if (plan.plan === "confirm-stop-first") {
1346
+ this.pending = {
1347
+ prompt: `"${row.meta.name}" is running. Interrupt and attach? (y/N)`,
1348
+ onYes: () => this.done({ action: "attach", viewId: row.meta.id, stopFirst: true }),
1349
+ };
1350
+ this.mode = "confirm";
1351
+ return;
1352
+ }
1353
+ this.done({ action: "attach", viewId: row.meta.id, stopFirst: false });
1354
+ }
1355
+
1356
+ // ---- rendering ----------------------------------------------------------
1357
+
1358
+ invalidate(): void {
1359
+ /* stateless render; nothing cached */
1360
+ }
1361
+
1362
+ dispose(): void {
1363
+ /* poll interval is owned by the factory */
1364
+ this.prewarmScheduler.cancel();
1365
+ }
1366
+
1367
+ render(width: number): string[] {
1368
+ const lines = this.renderLines(width);
1369
+ // Self-heal frames (issue #88): pi-tui disables clearOnShrink while an
1370
+ // overlay is active ("overlays need the padding"), so a content shrink
1371
+ // under the dashboard overlay would leave stale rows forever. Force a
1372
+ // full clear on the first frame and on any content-line shrink.
1373
+ // The force MUST be deferred past the active doRender pass: calling
1374
+ // requestRender(true) synchronously here resets pi-tui's diff state
1375
+ // mid-frame (previousLines=[], previousWidth=-1), making the CURRENT frame
1376
+ // take the first-render no-clear branch, after which the deferred re-render
1377
+ // diffs identical content — nothing is ever cleared (issue #88 CR r1).
1378
+ // Deferred to nextTick, the reset lands between frames: the next doRender
1379
+ // sees previousWidth=-1 ≠ width → widthChanged → fullRender(true), a real
1380
+ // clear wrapped in DECSET 2026 synchronized output by pi-tui itself.
1381
+ if (this.needsFullClear || (this.lastContentLineCount != null && this.frameContentLineCount < this.lastContentLineCount)) {
1382
+ this.needsFullClear = false;
1383
+ process.nextTick(() => this.tui.requestRender(true));
1384
+ }
1385
+ this.lastContentLineCount = this.frameContentLineCount;
1386
+ return lines;
1387
+ }
1388
+
1389
+ private renderLines(width: number): string[] {
1390
+ const allRows = this.deps.service.rows();
1391
+ const needs = allRows.filter((r) => r.state?.semanticState === "needs_input").length;
1392
+ const working = allRows.filter((r) => r.state?.semanticState === "working").length;
1393
+ const completed = allRows.filter((r) => r.state?.semanticState === "completed").length;
1394
+ const unread = allRows.filter((r) => {
1395
+ const lastVisitedAt = r.state?.lastVisitedAt ?? null;
1396
+ const lastAgentActivityAt = r.state?.lastAgentActivityAt ?? null;
1397
+ return lastAgentActivityAt !== null && (lastVisitedAt === null || lastAgentActivityAt > lastVisitedAt);
1398
+ }).length;
1399
+ const lines: string[] = [];
1400
+ const ptyHealth = this.ptyHealth();
1401
+
1402
+ lines.push(...this.renderOverview(width, { needs, working, completed, unread }, ptyHealth));
1403
+ if (!ptyHealth.ok && ptyHealth.issue) lines.push(...renderPtyWarningBanner(this.theme, ptyHealth.issue, width));
1404
+
1405
+ if (this.mode === "help") return this.fitToHeight(this.renderHelp(width), width);
1406
+ if (this.mode === "ptyHelp") return this.fitToHeight(this.renderPtyHelp(width, ptyHealth), width);
1407
+ if (this.mode === "peek" || this.mode === "reply") return this.fitToHeight(lines.concat(this.renderPeek(width)), width);
1408
+ if (this.mode === "session") return this.fitToHeight(lines.concat(this.renderSession(width)), width);
1409
+ if (this.mode === "evidence") return this.fitToHeight(lines.concat(this.renderEvidence(width)), width);
1410
+ if (this.mode === "launch") return this.fitToHeight(lines.concat(this.renderLaunch(width)), width);
1411
+
1412
+ // Body: grouped rows with a scroll viewport.
1413
+ const footer = this.renderFooter(width);
1414
+ const capacity = Math.max(1, (this.tui.terminal?.rows ?? 24) - lines.length - footer.length);
1415
+ const body = this.renderRows(width);
1416
+ const windowed = this.windowBody(body, capacity);
1417
+ lines.push(...windowed.lines);
1418
+ // Shrink-detection count: recorded BEFORE the spacer fill — the spacer
1419
+ // pads the frame back out to the terminal height, so the padded (or
1420
+ // pre-fitToHeight-padding) total never shrinks and would mask row
1421
+ // removals, the exact ghosting trigger (issue #88). Non-list modes
1422
+ // return earlier and intentionally don't record.
1423
+ this.frameContentLineCount = lines.length;
1424
+ // Keep the compose box visually docked to the bottom instead of glued to the
1425
+ // final session row. This matches the Claude-style layout: list at top,
1426
+ // large calm workspace, input/footer at bottom.
1427
+ const spacer = Math.max(0, capacity - windowed.lines.length);
1428
+ for (let i = 0; i < spacer; i++) lines.push("");
1429
+
1430
+ lines.push(...footer);
1431
+ return this.fitToHeight(lines, width, false);
1432
+ }
1433
+
1434
+ /** Views with no input frame show the notice on the bottom row. With one, it takes the frame's bottom edge. */
1435
+ private fitToHeight(lines: string[], width: number, noticeOnLastRow = true): string[] {
1436
+ const height = this.tui.terminal?.rows ?? 24;
1437
+ const out = lines.slice(0, height).map((l) => clip(l, width));
1438
+ while (out.length < height) out.push("");
1439
+ const notice = noticeOnLastRow ? this.noticeText() : null;
1440
+ if (notice) out[height - 1] = clip(` ${notice}`, width);
1441
+ return out;
1442
+ }
1443
+
1444
+ private ptyHealth(): PtyHealth {
1445
+ return this.deps.service.ptyHealth?.() ?? {
1446
+ ok: false,
1447
+ reason: "unknown",
1448
+ issue: null,
1449
+ staleHosts: 0,
1450
+ liveHosts: 0,
1451
+ };
1452
+ }
1453
+
1454
+ private renderOverview(
1455
+ width: number,
1456
+ counts: { needs: number; working: number; completed: number; unread: number },
1457
+ ptyHealth: PtyHealth,
1458
+ ): string[] {
1459
+ return renderAgentboardHeader(width, this.theme, counts, ptyHealth, this.filterQuery);
1460
+ }
1461
+
1462
+ /** Framed as pi-frame frames its editor: what it is on the top edge, the keys on the bottom. */
1463
+ private renderFooter(width: number): string[] {
1464
+ const t = this.theme;
1465
+ const color = this.modeColor();
1466
+ const inner = Math.max(1, width - 4);
1467
+ if (this.mode === "confirm" && this.pending) {
1468
+ return renderFrame(t, "warning", width, { title: t.fg("warning", "confirm"), hints: this.noticeText() ?? "" }, [t.fg("warning", this.pending.prompt)]);
1469
+ }
1470
+ // Enter starts it right there, so the folder has to be in view first.
1471
+ let title = t.fg("muted", `new session in ${truncate(displayPath(this.newTaskCwd()), Math.max(10, inner - 20))}`);
1472
+ let hints = this.listHints();
1473
+ let body: string[];
1474
+ if (this.mode === "filter" || this.mode === "rename") {
1475
+ title = t.fg(color as never, t.bold(this.mode));
1476
+ hints = this.mode === "filter" ? ["enter apply", "esc clear"] : ["enter save", "esc cancel"];
1477
+ body = this.renderEditorInputLines(inner, { prompt: t.fg(color as never, "› "), continuation: "", editing: true, placeholder: "" });
1478
+ } else if (this.mode === "list" || this.mode === "dispatch" || this.mode === "select") {
1479
+ if (this.mode === "select") title = t.fg(color as never, t.bold("select"));
1480
+ body = this.taskInputLines(inner);
1481
+ } else {
1482
+ body = [];
1483
+ }
1484
+ return renderFrame(t, color, width, { title, hints: this.noticeText() ?? t.fg("dim", hints.join(" ")) }, body);
1485
+ }
1486
+
1487
+ private listHints(): string[] {
1488
+ const selected = this.selectedRow();
1489
+ const live = selected ? selected.hostAlive && isAgentBusy(selected) : false;
1490
+ const unread = this.unreadCount();
1491
+ const dots = unread > 0 ? [`•${unread} unread`] : [];
1492
+ if (this.mode === "dispatch") return ["enter start", "ctrl+enter start & attach", "ctrl+n options", "esc clear"];
1493
+ if (this.mode === "select") {
1494
+ return [`${this.selectionCount()} selected`, ...dots, "space toggle", "a all", "u clear", "d done", "ctrl+x delete", "esc cancel"];
1495
+ }
1496
+ return [selected ? (live ? "enter attach live" : "enter resume") : "type to start", "space peek", "ctrl+f filter", "ctrl+x stop", "? keys", ...dots];
1497
+ }
1498
+
1499
+ private hintLine(label: string, color: InputNoticeColor, hints: string[]): string {
1500
+ return `${this.theme.fg(color, this.theme.bold(label))} ${this.theme.fg("dim", hints.join(" "))}`;
1501
+ }
1502
+
1503
+ private modeColor(): string {
1504
+ switch (this.mode) {
1505
+ case "dispatch":
1506
+ case "reply":
1507
+ return "accent";
1508
+ case "filter":
1509
+ case "confirm":
1510
+ return "warning";
1511
+ case "rename":
1512
+ case "select":
1513
+ return "accent";
1514
+ default:
1515
+ return "borderMuted";
1516
+ }
1517
+ }
1518
+
1519
+ private taskInputLines(width: number): string[] {
1520
+ const editing = this.mode === "dispatch";
1521
+ return this.renderEditorInputLines(width, {
1522
+ prompt: this.theme.fg(editing ? "accent" : "muted", "› "),
1523
+ continuation: "",
1524
+ editing,
1525
+ placeholder: editing ? "" : "type a task to start a session",
1526
+ });
1527
+ }
1528
+
1529
+ private renderEditorInputLines(
1530
+ width: number,
1531
+ opts: { prompt: string; continuation: string; editing: boolean; placeholder?: string; maxVisible?: number },
1532
+ ): string[] {
1533
+ void opts.continuation;
1534
+ void opts.maxVisible;
1535
+ const text = this.input;
1536
+ if (!text && !opts.editing) {
1537
+ const contentWidth = Math.max(1, width - visibleWidth(opts.prompt));
1538
+ return [clip(`${opts.prompt}${clip(this.theme.fg("muted", opts.placeholder ?? ""), contentWidth)}`, width)];
1539
+ }
1540
+ // Its first line and the one above any command menu are its own rules, and the frame already draws those.
1541
+ const all = this.editor.render(width);
1542
+ const menu = (this.editor as unknown as { renderedAutocompleteHeight: number }).renderedAutocompleteHeight;
1543
+ const rendered = [...all.slice(1, all.length - menu - 1), ...all.slice(all.length - menu)];
1544
+ return opts.editing ? rendered : rendered.map((line) => stripInactiveEditorCursor(line));
1545
+ }
1546
+
1547
+ private renderRows(width: number): string[] {
1548
+ const t = this.theme;
1549
+ if (this.rows.length === 0) {
1550
+ return [t.fg("muted", this.filterQuery ? " No sessions match the filter." : " No background sessions yet. Type below and press Enter.")];
1551
+ }
1552
+ const now = Date.now();
1553
+ if (this.groupBy === "directory") return this.renderDirectories(width, now);
1554
+ const groups = groupRowsByFolder(this.rows, now);
1555
+ const out: string[] = [];
1556
+ for (const g of groups) {
1557
+ if (out.length > 0) out.push("");
1558
+ out.push(this.renderStageHeader(g.state, g.label, g.rowCount, width));
1559
+ const showFolderHeaders = g.showFolders;
1560
+ for (const folder of g.folders) {
1561
+ if (showFolderHeaders) out.push(this.renderFolderHeader(g.state, folder, width));
1562
+ for (const rv of folder.rows) {
1563
+ out.push(this.renderRow(rv, width, { showFolder: !showFolderHeaders, indent: showFolderHeaders ? 4 : 2 }));
1564
+ }
1565
+ }
1566
+ }
1567
+ return out;
1568
+ }
1569
+
1570
+ private renderDirectories(width: number, now: number): string[] {
1571
+ const t = this.theme;
1572
+ const out: string[] = [];
1573
+ for (const folder of groupRowsByDirectory(this.rows, now)) {
1574
+ if (out.length > 0) out.push("");
1575
+ const title = t.fg("accent", t.bold(`▾ ${folder.name}`));
1576
+ const count = t.fg("dim", ` ${folder.rows.length}`);
1577
+ const rule = Math.max(0, width - visibleWidth(`▾ ${folder.name} ${folder.rows.length}`) - 2);
1578
+ out.push(clip(`${title}${count} ${t.fg("borderMuted", "─".repeat(rule))}`, width));
1579
+ for (const rv of folder.rows) {
1580
+ const summary = `${GROUP_LABELS[rv.state]} · ${rv.summary}`;
1581
+ out.push(this.renderRow({ ...rv, summary }, width, { showFolder: false, indent: 2 }));
1582
+ }
1583
+ }
1584
+ return out;
1585
+ }
1586
+
1587
+ private renderStageHeader(state: keyof typeof GROUP_LABELS, label: string, count: number, width: number): string {
1588
+ const t = this.theme;
1589
+ const glyph = stateGlyph(state, state === "working");
1590
+ const title = stageFg(state, t.bold(`${glyph} ${label.toUpperCase()}`));
1591
+ const countText = t.fg("dim", ` ${count}`);
1592
+ const ruleWidth = Math.max(0, width - visibleWidth(`${glyph} ${label.toUpperCase()} ${count}`) - 2);
1593
+ return clip(`${title}${countText} ${t.fg("borderMuted", "─".repeat(ruleWidth))}`, width);
1594
+ }
1595
+
1596
+ private renderFolderHeader(state: keyof typeof GROUP_LABELS, folder: ReturnType<typeof groupRowsByFolder>[number]["folders"][number], width: number): string {
1597
+ const t = this.theme;
1598
+ const count = folder.rows.length;
1599
+ const selectedInside = folder.rows.some((row) => row.id === this.selectedId);
1600
+ const rail = selectedInside ? stageFg(state, "▌") : t.fg("dim", " ");
1601
+ const name = t.fg(selectedInside ? "text" : "muted", `▾ ${folder.name}`);
1602
+ const countText = t.fg("dim", ` ${count}`);
1603
+ const pathBudget = Math.max(0, width - visibleWidth(`${rail} ▾ ${folder.name} ${count}`) - 2);
1604
+ const path = pathBudget > 14 ? ` ${t.fg("dim", truncate(displayPath(folder.path), pathBudget))}` : "";
1605
+ return clip(` ${rail} ${name}${countText}${path}`, width);
1606
+ }
1607
+
1608
+ private renderRow(rv: ReturnType<typeof import("../core/rows.mjs")["rowView"]>, width: number, opts: { showFolder?: boolean; indent?: number } = {}): string {
1609
+ const t = this.theme;
1610
+ const selected = rv.id === this.selectedId;
1611
+ const indent = opts.indent ?? 0;
1612
+ const contentWidth = Math.max(20, width - indent);
1613
+ const glyph = stateGlyph(rv.state, rv.alive, rv.hostAlive, rv.unread);
1614
+ const marker = stageFg(rv.state, selected ? `›${glyph}` : ` ${glyph}`);
1615
+ const statusBadges = `${rv.reviewReady ? "✓ " : ""}${rv.diagnosticStalled ? "⏳ " : ""}${rv.staleHost ? "⌁ " : ""}${rv.evidenceErrorCount || rv.diagnosticErrorCount ? "! " : ""}${rv.followUpCount ? `q${rv.followUpCount} ` : ""}${rv.steeringState && rv.steeringState !== "none" ? "π " : ""}`;
1616
+ const refsBadge = rv.refsBadge ? (rv.refsLowConfidence ? t.fg("dim", rv.refsBadge) : rv.refsBadge) : "";
1617
+ const badge = `${this.mode === "select" ? `${this.isSelectedForBatch(rv.id) ? "◉" : "○"} ` : ""}${rv.pinned ? "★ " : ""}${rv.worktree ? "⌥ " : ""}${statusBadges}${refsBadge ? `${refsBadge} ` : ""}`;
1618
+ const ageRaw = ` ${rv.age}`;
1619
+ const nameW = clamp(Math.floor(contentWidth * 0.34), 18, 30);
1620
+ const folderW = opts.showFolder !== false ? (contentWidth >= 72 ? clamp(Math.floor(contentWidth * 0.16), 10, 22) : contentWidth >= 56 ? 10 : 0) : 0;
1621
+ const availableName = Math.max(8, nameW - visibleWidth(badge));
1622
+ const nameText = padTo(`${badge}${truncate(rv.name, availableName)}`, nameW);
1623
+ const folderText = folderW > 0 ? padTo(truncate(rv.place, folderW), folderW) : "";
1624
+ const folder = folderW > 0 ? ` ${t.fg("dim", folderText)}` : "";
1625
+ const summaryW = Math.max(8, contentWidth - nameW - visibleWidth(folder) - visibleWidth(ageRaw) - 4);
1626
+ const summary = truncate(rv.summary, summaryW);
1627
+ let line = `${marker} ${t.fg(selected ? "text" : "muted", selected ? t.bold(nameText) : nameText)}${folder} ${t.fg(selected ? "text" : "muted", summary)}`;
1628
+ line = padTo(line, contentWidth - visibleWidth(ageRaw));
1629
+ line += t.fg("dim", ageRaw);
1630
+ // Issue #231: paint `indent + body` so the background opens at column 0 and
1631
+ // covers the trailing pad and the age column. `bg` closes inside the string,
1632
+ // so a clipped row cannot leak into the next line.
1633
+ const body = clip(line, contentWidth);
1634
+ const painted = applyRowHighlight(t, selected, `${" ".repeat(indent)}${body}`);
1635
+ return clip(painted, width);
1636
+ }
1637
+
1638
+ private renderPeek(width: number): string[] {
1639
+ const t = this.theme;
1640
+ const row = this.selectedRow();
1641
+ if (!row) return [t.fg("muted", " (no session)")];
1642
+ const st = rowState(row);
1643
+ const out: string[] = [];
1644
+ out.push(`${stageFg(st, stateGlyph(st, row.alive, row.hostAlive))} ${t.fg("accent", t.bold(row.meta.name))} ${t.fg("muted", `[${st}${row.alive ? " · alive" : ""}${row.hostAlive ? " · hosted" : ""}]`)}`);
1645
+ out.push(t.fg("dim", ` ${row.meta.repoCwd}${row.meta.worktreeMode === "worktree" ? " (worktree)" : ""}`));
1646
+ out.push("");
1647
+ out.push(t.fg("muted", "Summary"));
1648
+ out.push(clip(` ${normalizeGenericStatusText(st, row.state?.summary)}`, width));
1649
+ if (row.state?.autoState) {
1650
+ const auto = row.state.autoState;
1651
+ out.push("");
1652
+ out.push(t.fg("muted", "Auto-state"));
1653
+ out.push(clip(` ${auto.kind} · ${auto.confidence} · ${auto.source}${auto.reason ? ` — ${auto.reason}` : ""}`, width));
1654
+ }
1655
+ if (row.codeRefs && row.codeRefs.allRefs.length > 0) {
1656
+ const codeRefs = row.codeRefs;
1657
+ out.push("");
1658
+ out.push(t.fg("muted", "Refs"));
1659
+ if (codeRefs.provider) out.push(clip(` ${codeRefs.provider}`, width));
1660
+ for (const ref of codeRefs.allRefs) {
1661
+ const prefix = ref.kind === "pr" ? (codeRefs.prPrefix ?? "▸#") : (codeRefs.issuePrefix ?? "#");
1662
+ const line = ` ${ref.kind} ${prefix}${ref.number} · ${ref.confidence} · ${ref.source}${ref.url ? ` · ${ref.url}` : ""}`;
1663
+ out.push(clip(line, width));
1664
+ }
1665
+ }
1666
+ if (row.state?.question) {
1667
+ out.push("");
1668
+ out.push(t.fg("warning", "Question / blocker"));
1669
+ out.push(...wrap(` ${row.state.question}`, width));
1670
+ }
1671
+ out.push("");
1672
+ out.push(t.fg("muted", "Latest output"));
1673
+ out.push(...wrap(` ${row.state?.latestAssistantPreview || "—"}`, width, 8));
1674
+ if (row.state?.error) {
1675
+ out.push("");
1676
+ out.push(t.fg("error", "Error"));
1677
+ out.push(...wrap(` ${row.state.error}`, width, 4));
1678
+ }
1679
+ out.push(t.fg(this.mode === "reply" ? "success" : "dim", "─".repeat(width)));
1680
+ if (this.mode === "reply") {
1681
+ out.push(
1682
+ ...this.renderEditorInputLines(width, {
1683
+ prompt: t.fg("accent", "› "),
1684
+ continuation: t.fg("success", "reply┃ "),
1685
+ editing: true,
1686
+ placeholder: "",
1687
+ }),
1688
+ );
1689
+ out.push(clip(this.hintLine("reply", "accent", ["enter send", "shift+enter newline", "esc back"]), width));
1690
+ } else {
1691
+ out.push(clip(t.fg("dim", "type to reply ↑↓ prev/next ←/→ attach ctrl+d done ctrl+o transcript ctrl+e evidence esc back"), width));
1692
+ }
1693
+ return out;
1694
+ }
1695
+
1696
+ private renderEvidence(width: number): string[] {
1697
+ const t = this.theme;
1698
+ const row = this.selectedRow();
1699
+ if (!row) return [t.fg("muted", " (no session)")];
1700
+ const evidenceRes = this.deps.service.evidence?.(row.meta.id);
1701
+ const diagnosticsRes = this.deps.service.diagnostics?.(row.meta.id, { limit: 50 });
1702
+ const panel = buildEvidencePanel({
1703
+ row,
1704
+ evidence: evidenceRes?.ok ? evidenceRes.evidence : null,
1705
+ diagnostics: diagnosticsRes?.ok ? (diagnosticsRes.events ?? []) : [],
1706
+ paths: evidenceRes?.ok ? evidenceRes.paths : {},
1707
+ width,
1708
+ });
1709
+ const out: string[] = [];
1710
+ const capacity = Math.max(3, (this.tui.terminal?.rows ?? 24) - 5);
1711
+ out.push(...panel.slice(this.evidenceScrollTop, this.evidenceScrollTop + capacity).map((line: string) => clip(line, width)));
1712
+ out.push(t.fg("dim", "─".repeat(width)));
1713
+ out.push(clip(t.fg("dim", "←/< back · ↑↓/j/k scroll · pgup/pgdn page · enter attach · r reply · v transcript · x clear diagnostics"), width));
1714
+ return out;
1715
+ }
1716
+
1717
+ private renderSession(width: number): string[] {
1718
+ const t = this.theme;
1719
+ const row = this.selectedRow();
1720
+ if (!row) return [t.fg("muted", " (no session)")];
1721
+ const st = rowState(row);
1722
+ const out: string[] = [];
1723
+ const session = loadSessionView(row.meta.sessionFile);
1724
+ out.push(`${stageFg(st, stateGlyph(st, row.alive, row.hostAlive))} ${t.fg("accent", t.bold(row.meta.name))} ${t.fg("muted", `[session view${row.alive ? " · live" : ""}${row.hostAlive ? " · hosted" : ""}]`)}`);
1725
+ out.push(t.fg("dim", ` ${row.meta.repoCwd}${row.meta.worktreeMode === "worktree" ? " (worktree)" : ""}`));
1726
+ if (session.header?.cwd && session.header.cwd !== row.meta.repoCwd) {
1727
+ out.push(t.fg("dim", ` session cwd: ${session.header.cwd}`));
1728
+ }
1729
+ out.push("");
1730
+
1731
+ const body: string[] = [];
1732
+ if (session.error) {
1733
+ body.push(t.fg("error", ` ${session.error}`));
1734
+ } else if (session.items.length === 0) {
1735
+ body.push(t.fg("muted", " Session transcript empty yet."));
1736
+ } else {
1737
+ for (const item of session.items) {
1738
+ body.push(this.renderSessionItemLabel(item, width));
1739
+ body.push(...wrap(` ${item.text}`, width, 10_000));
1740
+ body.push("");
1741
+ }
1742
+ if (body[body.length - 1] === "") body.pop();
1743
+ }
1744
+
1745
+ const capacity = Math.max(3, (this.tui.terminal?.rows ?? 24) - out.length - 3);
1746
+ out.push(...this.windowSession(body, capacity));
1747
+ out.push(t.fg("dim", "─".repeat(width)));
1748
+ out.push(clip(t.fg("dim", "←/< back · ↑↓/j/k scroll · pgup/pgdn page · enter attach · d done · r reply · space peek"), width));
1749
+ return out;
1750
+ }
1751
+
1752
+ private renderSessionItemLabel(item: ReturnType<typeof loadSessionView>["items"][number], width: number): string {
1753
+ const t = this.theme;
1754
+ const label = ` ${item.label}`;
1755
+ switch (item.role) {
1756
+ case "user":
1757
+ return clip(t.fg("accent", label), width);
1758
+ case "assistant":
1759
+ return clip(t.fg("success", label), width);
1760
+ case "custom":
1761
+ return clip(t.fg("warning", label), width);
1762
+ default:
1763
+ return clip(t.fg("muted", label), width);
1764
+ }
1765
+ }
1766
+
1767
+ private renderLaunch(width: number): string[] {
1768
+ const t = this.theme;
1769
+ const launch = this.launch;
1770
+ if (!launch) return [t.fg("muted", " Launch dialog unavailable.")];
1771
+ const boxWidth = clamp(Math.min(width - 4, 88), 48, Math.max(48, width - 2));
1772
+ const inner = Math.max(20, boxWidth - 4);
1773
+ const lines: string[] = [];
1774
+ lines.push(t.fg("accent", t.bold("Start session")));
1775
+ lines.push("");
1776
+ for (const line of wrap(`Prompt: ${this.input.trim()}`, inner, 3)) lines.push(line);
1777
+ lines.push("");
1778
+ lines.push(this.renderLaunchField(inner, 1, `cwd ${displayPath(launch.cwd)}`, launch.picker === null && launch.fieldIndex === 1));
1779
+ lines.push(this.renderLaunchField(inner, 2, `model ${launch.model ? formatLaunchModel(launch.model) : "default"}`, launch.picker === null && launch.fieldIndex === 2));
1780
+ lines.push(this.renderLaunchField(inner, 3, `thinking ${launch.thinking}`, launch.picker === null && launch.fieldIndex === 3));
1781
+ lines.push(this.renderLaunchField(inner, 4, `action ${launch.action === "attach" ? "start & attach" : "start in background"}`, launch.picker === null && launch.fieldIndex === 4));
1782
+ lines.push("");
1783
+ if (launch.picker === "cwd") {
1784
+ lines.push(t.fg("warning", `cwd› ${singleLineInput(launch.cwdQuery)}${cursor()}`));
1785
+ if (launch.cwdPickerMode === "favorites") {
1786
+ const counts = new Map(launch.cwdRanked.map((entry) => [entry.path, entry.count]));
1787
+ lines.push(...this.renderLaunchSuggestions(inner, launch.cwdSuggestions, launch.cwdSuggestionIndex, (value) => {
1788
+ const count = counts.get(value) ?? 0;
1789
+ return `${displayPath(value)}${count > 0 ? ` ${count}×` : ""}`;
1790
+ }));
1791
+ lines.push("");
1792
+ lines.push(t.fg("dim", "常用目录 · type to search · tab complete · enter choose · esc back"));
1793
+ } else {
1794
+ lines.push(...this.renderLaunchSuggestions(inner, launch.cwdSuggestions, launch.cwdSuggestionIndex, (value) => displayPath(value)));
1795
+ lines.push("");
1796
+ lines.push(t.fg("dim", "type to filter folders · tab complete · enter choose · esc back"));
1797
+ }
1798
+ } else if (launch.picker === "model") {
1799
+ lines.push(t.fg("warning", `model› ${singleLineInput(launch.modelQuery)}${cursor()}`));
1800
+ lines.push(...this.renderLaunchSuggestions(inner, launch.modelFiltered, launch.modelIndex, (choice) => `${formatLaunchModel(choice.model)}${choice.thinkingLevel ? ` · ${choice.thinkingLevel}` : ""}`));
1801
+ lines.push("");
1802
+ lines.push(t.fg("dim", `showing ${launch.scopeSource === "scoped" ? "scoped" : "available"} models · type to filter · enter choose · esc back`));
1803
+ } else if (launch.picker === "thinking") {
1804
+ lines.push(t.fg("warning", "thinking"));
1805
+ lines.push(...this.renderLaunchSuggestions(inner, launch.thinkingOptions, launch.thinkingIndex, (value) => value));
1806
+ lines.push("");
1807
+ lines.push(t.fg("dim", "↑↓ choose · enter select · esc back"));
1808
+ } else {
1809
+ lines.push(t.fg("dim", "↑↓ move · enter open/select/toggle · esc cancel"));
1810
+ }
1811
+ lines.push("");
1812
+ lines.push(this.renderLaunchButton(inner, launch.picker === null && launch.fieldIndex === 0));
1813
+ return renderCenteredBox(lines, width, this.tui.terminal?.rows ?? 24, t);
1814
+ }
1815
+
1816
+ private renderLaunchField(width: number, _index: number, text: string, selected: boolean): string {
1817
+ const prefix = selected ? this.theme.fg("accent", "› ") : this.theme.fg("dim", " ");
1818
+ const label = clip(text, Math.max(1, width - 2));
1819
+ return padTo(`${prefix}${selected ? this.theme.fg("accent", label) : label}`, width);
1820
+ }
1821
+
1822
+ private renderLaunchButton(width: number, selected: boolean): string {
1823
+ const label = ` ${this.launch?.action === "attach" ? "Start & attach" : "Start session"} `;
1824
+ const button = selected
1825
+ ? this.theme.bg("selectedBg", this.theme.fg("accent", this.theme.bold(label)))
1826
+ : this.theme.fg("muted", label);
1827
+ const pad = Math.max(0, width - visibleWidth(label));
1828
+ return `${" ".repeat(pad)}${button}`;
1829
+ }
1830
+
1831
+ private renderLaunchSuggestions<T>(width: number, items: T[], index: number, renderItem: (item: T) => string): string[] {
1832
+ if (items.length === 0) return [this.theme.fg("muted", " no matches")];
1833
+ const start = Math.max(0, Math.min(index - 2, Math.max(0, items.length - 6)));
1834
+ const end = Math.min(items.length, start + 6);
1835
+ const out: string[] = [];
1836
+ for (let i = start; i < end; i++) {
1837
+ const selected = i === index;
1838
+ const prefix = selected ? this.theme.fg("accent", "→ ") : this.theme.fg("dim", " ");
1839
+ const body = clip(renderItem(items[i]), Math.max(1, width - 2));
1840
+ out.push(`${prefix}${selected ? this.theme.fg("accent", body) : body}`);
1841
+ }
1842
+ if (items.length > end) out.push(this.theme.fg("dim", ` ↓ ${items.length - end} more`));
1843
+ return out;
1844
+ }
1845
+
1846
+ private renderHelp(width: number): string[] {
1847
+ const t = this.theme;
1848
+ const rows: Array<[string, string]> = [
1849
+ ["type", "Start writing a task, with no key first"],
1850
+ ["enter", "With a task: start it. Empty: attach to the selected session"],
1851
+ ["ctrl+enter", "Start the task and attach to it"],
1852
+ ["shift+enter", "New line (ctrl+j too)"],
1853
+ ["ctrl+n", "While typing: choose the folder, model and thinking first"],
1854
+ ["← / →", "Attach to the selected session"],
1855
+ ["↑ / ↓", "Move between rows"],
1856
+ ["pgup / pgdn", "Move by a screenful"],
1857
+ ["home / end", "First or last row"],
1858
+ ["alt+↑ / alt+↓", "Previous or next group"],
1859
+ ["alt+1..9", "Attach to that row in this folder"],
1860
+ ["space", "Open or close the peek panel"],
1861
+ ["/", "Pi's commands. They run here; a template or skill starts a session"],
1862
+ ["ctrl+f", "Filter the list"],
1863
+ ["ctrl+s", "Group by state or by directory"],
1864
+ ["shift+↑ / ↓", "Move the row up or down its group"],
1865
+ ["ctrl+g", "Write the task in $VISUAL or $EDITOR"],
1866
+ ["ctrl+t", "Pin or unpin"],
1867
+ ["ctrl+r", "Rename"],
1868
+ ["ctrl+x", "Stop the session; press again within 2s to delete it"],
1869
+ ["ctrl+d", "Mark done"],
1870
+ ["ctrl+b", "Hold or unhold"],
1871
+ ["ctrl+a", "Select several rows"],
1872
+ ["ctrl+o", "Read-only transcript"],
1873
+ ["ctrl+e", "Evidence and diagnostics"],
1874
+ ["ctrl+k", "Delete every inactive session in this state"],
1875
+ ["esc", "Close, clear the input, or leave the board"],
1876
+ ["ctrl+c", "Clear the input; press twice to quit Pi, here or in a session"],
1877
+ ["!", "node-pty diagnostics"],
1878
+ ["?", "This help"],
1879
+ ["In select", "↑↓ move · space toggle · a all · u clear · d done · ctrl+x delete · esc cancel"],
1880
+ ["In peek", "type to reply · ←/→ attach · ↑↓ adjacent · ctrl+o transcript · ctrl+e evidence"],
1881
+ ["In session", "← back · ↑↓ scroll · e evidence · enter attach · r reply"],
1882
+ ["In evidence", "← back · ↑↓ scroll · → attach · r reply · v transcript · x clear diag"],
1883
+ ];
1884
+ const dialog = [t.fg("accent", t.bold("Keys")), ""];
1885
+ for (const [k, v] of rows) dialog.push(` ${t.fg("accent", k.padEnd(14))} ${t.fg("muted", v)}`);
1886
+ dialog.push("");
1887
+ dialog.push(t.fg("dim", ` press any key to return to ${this.helpReturnMode}`));
1888
+ return renderCenteredBox(dialog, width, this.tui.terminal?.rows ?? 24, t);
1889
+ }
1890
+
1891
+ private renderPtyHelp(width: number, health: PtyHealth): string[] {
1892
+ const t = this.theme;
1893
+ const dialog: string[] = [t.fg("accent", t.bold("node-pty diagnostics")), ""];
1894
+ const wrapWidth = Math.max(32, width - 16);
1895
+ if (health.ok) {
1896
+ dialog.push(t.fg("success", " Status: healthy"));
1897
+ dialog.push(t.fg("muted", ` Live hosts: ${health.liveHosts} · stale hosts: ${health.staleHosts}`));
1898
+ dialog.push("");
1899
+ dialog.push(t.fg("dim", ` press any key to return to ${this.ptyHelpReturnMode}`));
1900
+ return renderCenteredBox(dialog, width, this.tui.terminal?.rows ?? 24, t);
1901
+ }
1902
+ const issue = health.issue;
1903
+ dialog.push(t.fg("warning", ` Status: unavailable · ${issue?.statusLabel ?? "unknown cause"}`));
1904
+ if (issue?.summary) {
1905
+ dialog.push("");
1906
+ dialog.push(...wrap(` ${issue.summary}`, wrapWidth, 8));
1907
+ }
1908
+ if (issue?.fixHint) dialog.push(t.fg("warning", ` Fix: ${issue.fixHint}`));
1909
+ if (issue?.steps?.length) {
1910
+ dialog.push("");
1911
+ dialog.push(t.fg("accent", " Suggested steps"));
1912
+ for (const step of issue.steps) {
1913
+ if (!step.trim()) continue;
1914
+ if (step.endsWith(":")) dialog.push(t.fg("muted", ` ${step}`));
1915
+ else dialog.push(...wrap(` ${step}`, wrapWidth, 10));
1916
+ }
1917
+ }
1918
+ if (issue?.rawReason || health.reason) {
1919
+ dialog.push("");
1920
+ dialog.push(t.fg("accent", " Raw reason"));
1921
+ dialog.push(...wrap(` ${issue?.rawReason ?? health.reason ?? "unknown"}`, wrapWidth, 8));
1922
+ }
1923
+ dialog.push("");
1924
+ dialog.push(t.fg("dim", ` press any key to return to ${this.ptyHelpReturnMode}`));
1925
+ return renderCenteredBox(dialog, width, this.tui.terminal?.rows ?? 24, t);
1926
+ }
1927
+
1928
+ /** Keep the selected row visible within `capacity` lines, with scroll markers. */
1929
+ private windowBody(body: string[], capacity: number): { lines: string[] } {
1930
+ if (body.length <= capacity) {
1931
+ this.scrollTop = 0;
1932
+ return { lines: body };
1933
+ }
1934
+ // Find the line index of the selected row (best effort: match its rendered prefix).
1935
+ let selLine = 0;
1936
+ if (this.selectedId) {
1937
+ const idx = body.findIndex((l) => l.includes("›"));
1938
+ selLine = idx >= 0 ? idx : 0;
1939
+ }
1940
+ // The boundary lines can become scroll markers, so keep selection inside
1941
+ // them. On tiny viewports, prioritize the selected row over markers.
1942
+ const margin = capacity >= 3 ? 1 : 0;
1943
+ if (selLine < this.scrollTop + margin) this.scrollTop = selLine - margin;
1944
+ if (selLine >= this.scrollTop + capacity - margin) this.scrollTop = selLine - capacity + margin + 1;
1945
+ this.scrollTop = Math.max(0, Math.min(this.scrollTop, body.length - capacity));
1946
+ // The first session can follow both a stage and a folder header. Those
1947
+ // lines aren't selectable, so reveal them when the first session fits.
1948
+ if (this.selectedId === this.orderedIds[0] && selLine < capacity) this.scrollTop = 0;
1949
+ const slice = body.slice(this.scrollTop, this.scrollTop + capacity);
1950
+ const more = body.length - this.scrollTop - capacity;
1951
+ if (this.scrollTop > 0 && selLine !== this.scrollTop) slice[0] = this.theme.fg("dim", ` ↑ ${this.scrollTop + 1} more`);
1952
+ if (more > 0 && selLine !== this.scrollTop + capacity - 1) slice[slice.length - 1] = this.theme.fg("dim", ` ↓ ${more + 1} more`);
1953
+ return { lines: slice };
1954
+ }
1955
+
1956
+ private windowSession(body: string[], capacity: number): string[] {
1957
+ if (body.length <= capacity) {
1958
+ this.sessionScrollTop = 0;
1959
+ return body;
1960
+ }
1961
+ this.sessionScrollTop = Math.max(0, Math.min(this.sessionScrollTop, body.length - capacity));
1962
+ const slice = body.slice(this.sessionScrollTop, this.sessionScrollTop + capacity);
1963
+ const more = body.length - this.sessionScrollTop - capacity;
1964
+ if (this.sessionScrollTop > 0) slice[0] = this.theme.fg("dim", ` ↑ ${this.sessionScrollTop} more`);
1965
+ if (more > 0) slice[slice.length - 1] = this.theme.fg("dim", ` ↓ ${more} more`);
1966
+ return slice;
1967
+ }
1968
+ }
1969
+
1970
+ // ---- helpers --------------------------------------------------------------
1971
+
1972
+ function sameLaunchModel(a: LaunchModel | null | undefined, b: LaunchModel | null | undefined): boolean {
1973
+ return Boolean(a && b && a.provider === b.provider && a.id === b.id);
1974
+ }
1975
+
1976
+ function formatLaunchModel(model: LaunchModel): string {
1977
+ return `${model.provider}/${model.id}`;
1978
+ }
1979
+
1980
+ function filterLaunchChoices(choices: LaunchChoice[], query: string): LaunchChoice[] {
1981
+ const q = query.trim().toLowerCase();
1982
+ if (!q) return choices;
1983
+ return choices.filter((choice) => {
1984
+ const hay = `${choice.model.provider} ${choice.model.id} ${choice.model.name ?? ""} ${choice.model.provider}/${choice.model.id}`.toLowerCase();
1985
+ return hay.includes(q);
1986
+ });
1987
+ }
1988
+
1989
+ function findLaunchModelByRef(models: LaunchModel[], ref: string): LaunchModel | null {
1990
+ const target = String(ref || "").trim().toLowerCase();
1991
+ if (!target) return null;
1992
+ // Single source of truth with the service-side stale-model guard (issue #90):
1993
+ // same case-insensitive exact "provider/id" rule.
1994
+ return models.find((model) => modelRefAvailable(target, [model])) ?? null;
1995
+ }
1996
+
1997
+ function stripBracketedPaste(data: string): string {
1998
+ return data.replace(/\x1b\[200~|\x1b\[201~/g, "");
1999
+ }
2000
+
2001
+ function renderCenteredBox(lines: string[], width: number, height: number, theme: ThemeLike): string[] {
2002
+ const innerWidth = Math.max(20, Math.min(width - 6, Math.max(...lines.map((line) => visibleWidth(line)), 20)));
2003
+ const boxWidth = Math.min(width, innerWidth + 4);
2004
+ const left = Math.max(0, Math.floor((width - boxWidth) / 2));
2005
+ const box = [
2006
+ `${theme.fg("borderMuted", "╭")}${theme.fg("borderMuted", "─".repeat(Math.max(0, boxWidth - 2)))}${theme.fg("borderMuted", "╮")}`,
2007
+ ...lines.map((line) => `${theme.fg("borderMuted", "│")} ${padTo(clip(line, boxWidth - 4), boxWidth - 4)} ${theme.fg("borderMuted", "│")}`),
2008
+ `${theme.fg("borderMuted", "╰")}${theme.fg("borderMuted", "─".repeat(Math.max(0, boxWidth - 2)))}${theme.fg("borderMuted", "╯")}`,
2009
+ ];
2010
+ const top = Math.max(0, Math.floor((height - box.length) / 2) - 1);
2011
+ const out: string[] = [];
2012
+ for (let i = 0; i < top; i++) out.push("");
2013
+ for (const line of box) out.push(`${" ".repeat(left)}${line}`);
2014
+ return out;
2015
+ }
2016
+
2017
+ const DELETE_DOUBLE_PRESS_MS = 2_000;
2018
+
2019
+ const AGENTBOARD_VERSION = readAgentBoardVersion();
2020
+ function readAgentBoardVersion(): string {
2021
+ try {
2022
+ const raw = readFileSync(new URL("../../package.json", import.meta.url), "utf8");
2023
+ const pkg = JSON.parse(raw) as { version?: string };
2024
+ return pkg.version ? `v${pkg.version}` : "dev";
2025
+ } catch {
2026
+ return "dev";
2027
+ }
2028
+ }
2029
+ // ANSI rendering of the Pi logo.
2030
+ type HeaderCounts = { needs: number; working: number; completed: number; unread: number };
2031
+
2032
+ /** Corners and edges as pi-frame draws them, with text set into the top and bottom edges. */
2033
+ function renderFrame(
2034
+ theme: ThemeLike,
2035
+ color: string,
2036
+ width: number,
2037
+ edges: { title?: string; right?: string; hints?: string },
2038
+ body: string[],
2039
+ ): string[] {
2040
+ const paint = (text: string) => theme.fg(color as never, text);
2041
+ const edge = (left: string, right: string, text: string, side: string) => {
2042
+ const set = text ? ` ${text} ` : "";
2043
+ const end = side ? ` ${side} ${paint("─")}` : "";
2044
+ const room = width - 3 - visibleWidth(end);
2045
+ const shown = visibleWidth(set) > room ? clip(set, Math.max(0, room)) : set;
2046
+ return `${paint(`${left}─`)}${shown}${paint("─".repeat(Math.max(0, room - visibleWidth(shown))))}${end}${paint(right)}`;
2047
+ };
2048
+ const inner = Math.max(1, width - 4);
2049
+ return [
2050
+ edge("╭", "╮", edges.title ?? "", edges.right ?? ""),
2051
+ ...body.map((line) => `${paint("│")} ${padTo(clip(line, inner), inner)} ${paint("│")}`),
2052
+ edge("╰", "╯", edges.hints ?? "", ""),
2053
+ ];
2054
+ }
2055
+
2056
+ function renderAgentboardHeader(
2057
+ width: number,
2058
+ theme: ThemeLike,
2059
+ counts: HeaderCounts,
2060
+ ptyHealth: PtyHealth,
2061
+ filterQuery: string,
2062
+ ): string[] {
2063
+ const title = `${theme.fg("accent", theme.bold("π-board"))} ${theme.fg("muted", AGENTBOARD_VERSION.replace(/^v/, ""))}`;
2064
+ const narrow = width < 60;
2065
+ return renderFrame(
2066
+ theme,
2067
+ "borderMuted",
2068
+ width,
2069
+ { title, right: narrow ? "" : renderPtyHealth(theme, ptyHealth) },
2070
+ [headerStageSummary(theme, counts, filterQuery, narrow)],
2071
+ );
2072
+ }
2073
+
2074
+ function headerStageSummary(theme: ThemeLike, counts: HeaderCounts, filterQuery: string, compact: boolean): string {
2075
+ const joiner = theme.fg("dim", " · ");
2076
+ const parts = [
2077
+ headerStagePart(theme, "needs_input", counts.needs, compact ? "answer" : "needs answer"),
2078
+ headerStagePart(theme, "working", counts.working, "running"),
2079
+ headerUnreadPart(theme, counts.unread),
2080
+ headerStagePart(theme, "completed", counts.completed, "done"),
2081
+ ];
2082
+ if (filterQuery) parts.push(theme.fg("warning", `filter:${filterQuery}`));
2083
+ return parts.join(joiner);
2084
+ }
2085
+
2086
+ function headerUnreadPart(theme: ThemeLike, count: number): string {
2087
+ return `${theme.fg(count > 0 ? "accent" : "dim", `•${count}`)} ${theme.fg("dim", "unread")}`;
2088
+ }
2089
+
2090
+ function headerStagePart(theme: ThemeLike, state: keyof typeof GROUP_LABELS, count: number, label: string): string {
2091
+ return `${stageFg(state, String(count))} ${theme.fg("dim", label)}`;
2092
+ }
2093
+
2094
+ function renderPtyHealth(theme: ThemeLike, health: PtyHealth): string {
2095
+ const text = ptyHealthText(health);
2096
+ if (!health.ok) return theme.fg("error", text);
2097
+ if (health.staleHosts > 0) return theme.fg("warning", text);
2098
+ return theme.fg("success", text);
2099
+ }
2100
+
2101
+ function renderPtyWarningBanner(_theme: ThemeLike, issue: PtyIssue, width: number): string[] {
2102
+ return renderFlashBanner(
2103
+ { text: `${issue.summary} Fix: ${issue.fixHint} Press ! for exact steps.`, level: "warn" },
2104
+ width,
2105
+ { bottomGap: true },
2106
+ );
2107
+ }
2108
+
2109
+ function ptyHealthText(health: PtyHealth): string {
2110
+ if (!health.ok) return `node-pty unavailable${health.issue?.statusLabel ? ` · ${health.issue.statusLabel}` : health.reason ? ` (${compactPtyReason(health.reason)})` : ""}`;
2111
+ if (health.staleHosts > 0) return `node-pty degraded · stale:${health.staleHosts}`;
2112
+ return health.liveHosts > 0 ? `node-pty healthy · hosts:${health.liveHosts}` : "node-pty healthy";
2113
+ }
2114
+
2115
+ function compactPtyReason(reason: string): string {
2116
+ const cleaned = String(reason || "").replace(/\s+/g, " ").trim();
2117
+ if (!cleaned) return "unknown";
2118
+ const first = firstSentence(cleaned);
2119
+ return truncate(first.length >= 12 ? first : cleaned, 44);
2120
+ }
2121
+
2122
+ /** From the Pi theme, as pi-frame's colors are. */
2123
+ const STAGE_TOKENS: Record<keyof typeof GROUP_LABELS, string> = {
2124
+ queued: "muted",
2125
+ working: "accent",
2126
+ needs_input: "warning",
2127
+ idle: "syntaxType",
2128
+ holding: "syntaxNumber",
2129
+ completed: "success",
2130
+ failed: "error",
2131
+ stopped: "dim",
2132
+ };
2133
+
2134
+ /** Set by the component: stageFg is called from places that aren't handed a theme. */
2135
+ let stageTheme: ThemeLike | null = null;
2136
+
2137
+
2138
+ function stageFg(state: keyof typeof GROUP_LABELS, text: string): string {
2139
+ return stageTheme ? stageTheme.fg(STAGE_TOKENS[state] as never, text) : text;
2140
+ }
2141
+
2142
+ /**
2143
+ * Issue #231: the selected row's full-line background. Pure (the theme is
2144
+ * injected) so the wrap/close contract is unit-testable, and an unselected row
2145
+ * is returned byte-identical.
2146
+ */
2147
+ export function applyRowHighlight(
2148
+ theme: Pick<ThemeLike, "bg">,
2149
+ selected: boolean,
2150
+ text: string,
2151
+ ): string {
2152
+ return selected ? theme.bg("selectedBg", text) : text;
2153
+ }
2154
+
2155
+ function isPrintable(data: string): boolean {
2156
+ if (data.length === 0) return false;
2157
+ // Accept multi-char paste of printable text; reject control/escape sequences.
2158
+ return [...data].every((ch) => ch >= " " && ch !== "\x7f");
2159
+ }
2160
+
2161
+ function isBracketedPaste(data: string): boolean {
2162
+ return data.includes("\x1b[200~");
2163
+ }
2164
+
2165
+ function cursor(): string {
2166
+ return "\x1b[7m \x1b[27m";
2167
+ }
2168
+
2169
+ function stripInactiveEditorCursor(line: string): string {
2170
+ return line.replace(/\x1b\[7m([\s\S]*?)\x1b\[0m/, (_match, content: string) => (content === " " ? "" : content));
2171
+ }
2172
+
2173
+ function singleLineInput(text: string): string {
2174
+ return text.replace(/[\r\n]+/g, " ");
2175
+ }
2176
+
2177
+ function clip(line: string, width: number): string {
2178
+ return truncateToWidth(line, width, "");
2179
+ }
2180
+
2181
+ function padTo(line: string, width: number): string {
2182
+ const w = visibleWidth(line);
2183
+ return w >= width ? line : line + " ".repeat(width - w);
2184
+ }
2185
+
2186
+ function wrap(text: string, width: number, max = 6): string[] {
2187
+ const words = text.split(/\s+/);
2188
+ const lines: string[] = [];
2189
+ let cur = "";
2190
+ for (const word of words) {
2191
+ if (visibleWidth(cur) + visibleWidth(word) + 1 > width) {
2192
+ lines.push(cur);
2193
+ cur = ` ${word}`;
2194
+ } else {
2195
+ cur = cur ? `${cur} ${word}` : word;
2196
+ }
2197
+ if (lines.length >= max) break;
2198
+ }
2199
+ if (cur && lines.length < max) lines.push(cur);
2200
+ return lines.map((l) => clip(l, width));
2201
+ }
2202
+
2203
+ function isAgentBusy(row: Row): boolean {
2204
+ const st = row.state?.semanticState;
2205
+ const waitingOnTool = Array.isArray(row.state?.pendingQuestions) && row.state.pendingQuestions.length > 0;
2206
+ return Boolean(row.alive && (st === "queued" || st === "working" || waitingOnTool));
2207
+ }
2208
+
2209
+ function displayPath(path: string): string {
2210
+ const home = process.env.HOME;
2211
+ if (home && path.startsWith(home)) return `~${path.slice(home.length)}` || "~";
2212
+ return path;
2213
+ }
2214
+
2215
+ function clamp(n: number, min: number, max: number): number {
2216
+ return Math.max(min, Math.min(max, n));
2217
+ }
2218
+
2219
+ function editorTheme(theme: ThemeLike): EditorTheme {
2220
+ return {
2221
+ borderColor: (text: string) => theme.fg("borderMuted", text),
2222
+ selectList: {
2223
+ selectedPrefix: (text: string) => theme.fg("accent", text),
2224
+ selectedText: (text: string) => theme.fg("accent", text),
2225
+ description: (text: string) => theme.fg("muted", text),
2226
+ scrollInfo: (text: string) => theme.fg("muted", text),
2227
+ noMatch: (text: string) => theme.fg("muted", text),
2228
+ },
2229
+ };
2230
+ }
2231
+
2232
+ function renderFlashBanner(flash: { text: string; level: FlashLevel }, width: number, opts: { bottomGap?: boolean } = {}): string[] {
2233
+ const [icon, token] = flash.level === "warn" ? ["!", "warning"] : flash.level === "error" ? ["×", "error"] : ["i", "accent"];
2234
+ const text = ` ${icon} ${flash.text}`;
2235
+ const line = clip(stageTheme ? stageTheme.fg(token as never, text) : text, width);
2236
+ return opts.bottomGap ? [line, ""] : [line];
2237
+ }