@nemus-cli/nemus 0.2.1

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 (320) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/LICENSE +21 -0
  3. package/README.md +275 -0
  4. package/assets/README.md +53 -0
  5. package/assets/banner.png +0 -0
  6. package/assets/banner.svg +66 -0
  7. package/assets/favicon-32.png +0 -0
  8. package/assets/favicon-64.png +0 -0
  9. package/assets/icon-256.png +0 -0
  10. package/assets/icon.svg +23 -0
  11. package/assets/logo-256.png +0 -0
  12. package/assets/logo-512.png +0 -0
  13. package/assets/logo-wordmark.svg +35 -0
  14. package/assets/logo.svg +32 -0
  15. package/bin/workspace.js +130 -0
  16. package/dist/cli/ai-prompt.js +421 -0
  17. package/dist/cli/dashboard/AgentList.js +61 -0
  18. package/dist/cli/dashboard/DashboardSidebar.js +280 -0
  19. package/dist/cli/dashboard/HelpFooter.js +73 -0
  20. package/dist/cli/dashboard/StatusBadge.js +50 -0
  21. package/dist/cli/dashboard/index.js +11 -0
  22. package/dist/cli/dashboard/types.js +2 -0
  23. package/dist/cli/tui.js +48 -0
  24. package/dist/commands/ai-prompt.js +22 -0
  25. package/dist/commands/analyze-deps.js +138 -0
  26. package/dist/commands/archive.js +113 -0
  27. package/dist/commands/branch/create.js +119 -0
  28. package/dist/commands/branch/index.js +104 -0
  29. package/dist/commands/branch/merge.js +90 -0
  30. package/dist/commands/branch/rebase.js +84 -0
  31. package/dist/commands/branch/switch.js +165 -0
  32. package/dist/commands/cache/index.js +80 -0
  33. package/dist/commands/cache/manager.js +198 -0
  34. package/dist/commands/cleanup.js +153 -0
  35. package/dist/commands/configure-claude.js +55 -0
  36. package/dist/commands/configure.js +231 -0
  37. package/dist/commands/create.js +204 -0
  38. package/dist/commands/dashboard/hook-handler.js +162 -0
  39. package/dist/commands/dashboard/index.js +258 -0
  40. package/dist/commands/dashboard/launcher.js +40 -0
  41. package/dist/commands/dashboard/session-picker.js +106 -0
  42. package/dist/commands/dashboard/sidebar.js +39 -0
  43. package/dist/commands/dashboard/workspace-picker.js +95 -0
  44. package/dist/commands/delete.js +194 -0
  45. package/dist/commands/deprecated-aliases.js +44 -0
  46. package/dist/commands/diff.js +129 -0
  47. package/dist/commands/doctor.js +141 -0
  48. package/dist/commands/generate-docs.js +77 -0
  49. package/dist/commands/ghq-status.js +72 -0
  50. package/dist/commands/go.js +154 -0
  51. package/dist/commands/history.js +124 -0
  52. package/dist/commands/list.js +191 -0
  53. package/dist/commands/mcp/index.js +69 -0
  54. package/dist/commands/migrate.js +296 -0
  55. package/dist/commands/remove-repo.js +217 -0
  56. package/dist/commands/report-bug.js +112 -0
  57. package/dist/commands/run.js +147 -0
  58. package/dist/commands/save-context.js +171 -0
  59. package/dist/commands/sessions.js +129 -0
  60. package/dist/commands/status.js +138 -0
  61. package/dist/commands/suite/create.js +212 -0
  62. package/dist/commands/suite/delete.js +64 -0
  63. package/dist/commands/suite/export.js +126 -0
  64. package/dist/commands/suite/import.js +128 -0
  65. package/dist/commands/suite/index.js +94 -0
  66. package/dist/commands/suite/list.js +49 -0
  67. package/dist/commands/suite/use.js +227 -0
  68. package/dist/commands/sync.js +117 -0
  69. package/dist/commands/update.js +203 -0
  70. package/dist/mcp/install.js +550 -0
  71. package/dist/mcp/server.js +398 -0
  72. package/dist/mcp/tools.js +922 -0
  73. package/dist/pi-extensions/permission-sync.ts.template +41 -0
  74. package/dist/pi-extensions/permissions-gate.ts.template +69 -0
  75. package/dist/pi-extensions/pr-fix-trigger.ts.template +124 -0
  76. package/dist/pi-extensions/review-reminder.ts.template +58 -0
  77. package/dist/pi-extensions/workspace-input-status.ts.template +919 -0
  78. package/dist/program.js +136 -0
  79. package/dist/scripts/workspace-table.py +394 -0
  80. package/dist/types/dashboard.js +11 -0
  81. package/dist/types/index.js +2 -0
  82. package/dist/utils/agent-config.js +263 -0
  83. package/dist/utils/agent-state.js +178 -0
  84. package/dist/utils/banner.js +24 -0
  85. package/dist/utils/branch-operations.js +232 -0
  86. package/dist/utils/bug-report.js +355 -0
  87. package/dist/utils/cache.js +168 -0
  88. package/dist/utils/claude-integration.js +617 -0
  89. package/dist/utils/claude-sessions.js +260 -0
  90. package/dist/utils/cleanup-operations.js +120 -0
  91. package/dist/utils/colors.js +26 -0
  92. package/dist/utils/command-helpers.js +44 -0
  93. package/dist/utils/config.js +150 -0
  94. package/dist/utils/context-file.js +33 -0
  95. package/dist/utils/dashboard-hooks.js +172 -0
  96. package/dist/utils/dependency-analyzer.js +234 -0
  97. package/dist/utils/diff-operations.js +80 -0
  98. package/dist/utils/doc-generator.js +184 -0
  99. package/dist/utils/ghq-integration.js +332 -0
  100. package/dist/utils/git-operations.js +237 -0
  101. package/dist/utils/git-status.js +187 -0
  102. package/dist/utils/github.js +68 -0
  103. package/dist/utils/health-checks.js +345 -0
  104. package/dist/utils/history.js +150 -0
  105. package/dist/utils/hooks.js +100 -0
  106. package/dist/utils/logger.js +40 -0
  107. package/dist/utils/permission-sync.js +681 -0
  108. package/dist/utils/pi-extensions.js +224 -0
  109. package/dist/utils/progress.js +92 -0
  110. package/dist/utils/prompts.js +279 -0
  111. package/dist/utils/repo-resolver.js +260 -0
  112. package/dist/utils/retry.js +65 -0
  113. package/dist/utils/run-operations.js +30 -0
  114. package/dist/utils/suite.js +159 -0
  115. package/dist/utils/sync-operations.js +107 -0
  116. package/dist/utils/tmux-dashboard.js +184 -0
  117. package/dist/utils/validation.js +153 -0
  118. package/dist/utils/version-check.js +121 -0
  119. package/dist/utils/workspace-meta.js +185 -0
  120. package/install-shell-integration.sh +532 -0
  121. package/install.sh +401 -0
  122. package/package.json +90 -0
  123. package/remove-shell-block.awk +44 -0
  124. package/scripts/postinstall.js +85 -0
  125. package/skills/analyze-deps.md +23 -0
  126. package/skills/archive-workspace.md +26 -0
  127. package/skills/branch-create.md +18 -0
  128. package/skills/create-workspace.md +39 -0
  129. package/skills/delete-workspace.md +37 -0
  130. package/skills/list-org-repos.md +26 -0
  131. package/skills/list-suites.md +24 -0
  132. package/skills/list-workspaces.md +27 -0
  133. package/skills/refresh-workspace-docs.md +89 -0
  134. package/skills/remove-repo.md +25 -0
  135. package/skills/run-command.md +23 -0
  136. package/skills/save-context.md +68 -0
  137. package/skills/search-repos.md +31 -0
  138. package/skills/snapshot-list.md +18 -0
  139. package/skills/snapshot-save.md +12 -0
  140. package/skills/switch-branch.md +18 -0
  141. package/skills/update-cache.md +21 -0
  142. package/skills/update-workspace.md +66 -0
  143. package/skills/workspace-cleanup.md +18 -0
  144. package/skills/workspace-diff.md +23 -0
  145. package/skills/workspace-doctor.md +23 -0
  146. package/skills/workspace-info.md +45 -0
  147. package/skills/workspace-manager/SKILL.md +201 -0
  148. package/skills/workspace-manager/references/ai-prompt.md +44 -0
  149. package/skills/workspace-manager/references/analyze-deps.md +45 -0
  150. package/skills/workspace-manager/references/archive-workspace.md +38 -0
  151. package/skills/workspace-manager/references/branch-create.md +31 -0
  152. package/skills/workspace-manager/references/branch-merge.md +22 -0
  153. package/skills/workspace-manager/references/branch-rebase.md +22 -0
  154. package/skills/workspace-manager/references/cache-clear.md +18 -0
  155. package/skills/workspace-manager/references/cache-info.md +22 -0
  156. package/skills/workspace-manager/references/configure-claude.md +31 -0
  157. package/skills/workspace-manager/references/configure.md +35 -0
  158. package/skills/workspace-manager/references/create-workspace.md +45 -0
  159. package/skills/workspace-manager/references/dashboard.md +61 -0
  160. package/skills/workspace-manager/references/delete-workspace.md +33 -0
  161. package/skills/workspace-manager/references/generate-docs.md +36 -0
  162. package/skills/workspace-manager/references/ghq-status.md +28 -0
  163. package/skills/workspace-manager/references/go.md +33 -0
  164. package/skills/workspace-manager/references/history.md +41 -0
  165. package/skills/workspace-manager/references/list-org-repos.md +26 -0
  166. package/skills/workspace-manager/references/list-suites.md +21 -0
  167. package/skills/workspace-manager/references/list-workspaces.md +27 -0
  168. package/skills/workspace-manager/references/mcp.md +49 -0
  169. package/skills/workspace-manager/references/remove-repo.md +26 -0
  170. package/skills/workspace-manager/references/run-command.md +44 -0
  171. package/skills/workspace-manager/references/search-repos.md +24 -0
  172. package/skills/workspace-manager/references/sessions.md +27 -0
  173. package/skills/workspace-manager/references/suite-create.md +22 -0
  174. package/skills/workspace-manager/references/suite-delete.md +21 -0
  175. package/skills/workspace-manager/references/suite-export.md +19 -0
  176. package/skills/workspace-manager/references/suite-import.md +19 -0
  177. package/skills/workspace-manager/references/suite-use.md +27 -0
  178. package/skills/workspace-manager/references/switch-branch.md +25 -0
  179. package/skills/workspace-manager/references/update-cache.md +22 -0
  180. package/skills/workspace-manager/references/update-workspace.md +50 -0
  181. package/skills/workspace-manager/references/workspace-cleanup.md +33 -0
  182. package/skills/workspace-manager/references/workspace-diff.md +27 -0
  183. package/skills/workspace-manager/references/workspace-doctor.md +27 -0
  184. package/skills/workspace-manager/references/workspace-info.md +33 -0
  185. package/skills/workspace-manager/references/workspace-status.md +43 -0
  186. package/skills/workspace-manager/references/workspace-sync.md +26 -0
  187. package/skills/workspace-status.md +26 -0
  188. package/skills/workspace-sync.md +23 -0
  189. package/src/cli/ai-prompt.test.ts +667 -0
  190. package/src/cli/ai-prompt.ts +421 -0
  191. package/src/cli/dashboard/AgentList.tsx +44 -0
  192. package/src/cli/dashboard/DashboardSidebar.tsx +291 -0
  193. package/src/cli/dashboard/HelpFooter.tsx +22 -0
  194. package/src/cli/dashboard/StatusBadge.tsx +23 -0
  195. package/src/cli/dashboard/index.ts +5 -0
  196. package/src/cli/dashboard/types.ts +16 -0
  197. package/src/cli/tui.tsx +51 -0
  198. package/src/cli/workspace-cli.test.ts +30 -0
  199. package/src/commands/ai-prompt.ts +21 -0
  200. package/src/commands/analyze-deps.ts +119 -0
  201. package/src/commands/archive.ts +114 -0
  202. package/src/commands/branch/create.ts +94 -0
  203. package/src/commands/branch/index.ts +74 -0
  204. package/src/commands/branch/merge.ts +61 -0
  205. package/src/commands/branch/rebase.ts +54 -0
  206. package/src/commands/branch/switch.ts +150 -0
  207. package/src/commands/cache/index.ts +51 -0
  208. package/src/commands/cache/manager.ts +168 -0
  209. package/src/commands/cleanup.ts +132 -0
  210. package/src/commands/configure-claude.ts +55 -0
  211. package/src/commands/configure.ts +204 -0
  212. package/src/commands/create.ts +196 -0
  213. package/src/commands/dashboard/hook-handler.ts +138 -0
  214. package/src/commands/dashboard/index.ts +248 -0
  215. package/src/commands/dashboard/launcher.ts +42 -0
  216. package/src/commands/dashboard/session-picker.ts +73 -0
  217. package/src/commands/dashboard/sidebar.js +39 -0
  218. package/src/commands/dashboard/workspace-picker.ts +62 -0
  219. package/src/commands/delete.test.ts +250 -0
  220. package/src/commands/delete.ts +171 -0
  221. package/src/commands/deprecated-aliases.ts +55 -0
  222. package/src/commands/diff.ts +106 -0
  223. package/src/commands/doctor.ts +110 -0
  224. package/src/commands/generate-docs.ts +45 -0
  225. package/src/commands/ghq-status.ts +70 -0
  226. package/src/commands/go.ts +125 -0
  227. package/src/commands/history.ts +136 -0
  228. package/src/commands/list.test.ts +219 -0
  229. package/src/commands/list.ts +176 -0
  230. package/src/commands/mcp/index.ts +39 -0
  231. package/src/commands/migrate.ts +277 -0
  232. package/src/commands/remove-repo.ts +194 -0
  233. package/src/commands/report-bug.ts +88 -0
  234. package/src/commands/run.ts +125 -0
  235. package/src/commands/save-context.test.ts +113 -0
  236. package/src/commands/save-context.ts +141 -0
  237. package/src/commands/sessions.ts +99 -0
  238. package/src/commands/status.ts +111 -0
  239. package/src/commands/suite/create.ts +240 -0
  240. package/src/commands/suite/delete.ts +65 -0
  241. package/src/commands/suite/export.ts +97 -0
  242. package/src/commands/suite/import.ts +100 -0
  243. package/src/commands/suite/index.ts +66 -0
  244. package/src/commands/suite/list.ts +54 -0
  245. package/src/commands/suite/use.ts +232 -0
  246. package/src/commands/sync.ts +97 -0
  247. package/src/commands/update.ts +197 -0
  248. package/src/mcp/install.ts +529 -0
  249. package/src/mcp/server.ts +579 -0
  250. package/src/mcp/tools.test.ts +545 -0
  251. package/src/mcp/tools.ts +1019 -0
  252. package/src/mcp/update-workspace.test.ts +220 -0
  253. package/src/pi-extensions/permission-sync.ts.template +41 -0
  254. package/src/pi-extensions/permissions-gate.ts.template +69 -0
  255. package/src/pi-extensions/pr-fix-trigger.ts.template +124 -0
  256. package/src/pi-extensions/review-reminder.ts.template +58 -0
  257. package/src/pi-extensions/workspace-input-status.ts.template +919 -0
  258. package/src/program.ts +112 -0
  259. package/src/scripts/workspace-table.py +394 -0
  260. package/src/types/dashboard.ts +29 -0
  261. package/src/types/index.ts +109 -0
  262. package/src/types/ink.d.ts +171 -0
  263. package/src/utils/agent-config.test.ts +303 -0
  264. package/src/utils/agent-config.ts +264 -0
  265. package/src/utils/agent-state.test.ts +166 -0
  266. package/src/utils/agent-state.ts +135 -0
  267. package/src/utils/banner.ts +24 -0
  268. package/src/utils/branch-operations.ts +237 -0
  269. package/src/utils/bug-report.test.ts +174 -0
  270. package/src/utils/bug-report.ts +355 -0
  271. package/src/utils/cache.ts +150 -0
  272. package/src/utils/claude-integration.test.ts +381 -0
  273. package/src/utils/claude-integration.ts +649 -0
  274. package/src/utils/claude-sessions.test.ts +262 -0
  275. package/src/utils/claude-sessions.ts +251 -0
  276. package/src/utils/cleanup-operations.ts +94 -0
  277. package/src/utils/colors.ts +25 -0
  278. package/src/utils/command-helpers.ts +46 -0
  279. package/src/utils/config.test.ts +203 -0
  280. package/src/utils/config.ts +133 -0
  281. package/src/utils/context-file.ts +36 -0
  282. package/src/utils/dashboard-hooks.test.ts +168 -0
  283. package/src/utils/dashboard-hooks.ts +163 -0
  284. package/src/utils/dependency-analyzer.ts +243 -0
  285. package/src/utils/diff-operations.ts +59 -0
  286. package/src/utils/doc-generator.ts +187 -0
  287. package/src/utils/ghq-integration.test.ts +195 -0
  288. package/src/utils/ghq-integration.ts +317 -0
  289. package/src/utils/git-operations.test.ts +265 -0
  290. package/src/utils/git-operations.ts +233 -0
  291. package/src/utils/git-status.ts +171 -0
  292. package/src/utils/github.ts +74 -0
  293. package/src/utils/health-checks.ts +359 -0
  294. package/src/utils/history.ts +140 -0
  295. package/src/utils/hooks.ts +92 -0
  296. package/src/utils/logger.ts +37 -0
  297. package/src/utils/permission-sync.test.ts +593 -0
  298. package/src/utils/permission-sync.ts +720 -0
  299. package/src/utils/pi-extensions.test.ts +182 -0
  300. package/src/utils/pi-extensions.ts +226 -0
  301. package/src/utils/progress.ts +73 -0
  302. package/src/utils/prompts.test.ts +169 -0
  303. package/src/utils/prompts.ts +296 -0
  304. package/src/utils/repo-resolver.test.ts +313 -0
  305. package/src/utils/repo-resolver.ts +294 -0
  306. package/src/utils/retry.test.ts +124 -0
  307. package/src/utils/retry.ts +85 -0
  308. package/src/utils/run-operations.ts +38 -0
  309. package/src/utils/suite.test.ts +256 -0
  310. package/src/utils/suite.ts +144 -0
  311. package/src/utils/sync-operations.ts +87 -0
  312. package/src/utils/tmux-dashboard.ts +193 -0
  313. package/src/utils/validation.test.ts +139 -0
  314. package/src/utils/validation.ts +128 -0
  315. package/src/utils/version-check.test.ts +108 -0
  316. package/src/utils/version-check.ts +96 -0
  317. package/src/utils/workspace-meta.ts +173 -0
  318. package/sync-permissions.sh +137 -0
  319. package/tsconfig.json +20 -0
  320. package/uninstall.sh +162 -0
@@ -0,0 +1,919 @@
1
+ /**
2
+ * Workspace input status — Claude-Code-style layout (auto-generated by workspace-manager)
3
+ *
4
+ * Layout (matching the Claude Code reference):
5
+ *
6
+ * ──────────────────────────[ workspace-name ]────── ← editor top border
7
+ * │ > _ │ ← input box
8
+ * ────────────────────────────────────────────────── ← editor bottom border
9
+ * ⠋ ✦ sonnet-4-6 ⚡ high 📂 ws:foo (3) 🧠 24k/200k 34% 💰 $0.42 ⏱ 2m 5s
10
+ *
11
+ * REPO BRANCH PR DIRTY
12
+ * workspace-manager-fix-… feat/oauth #41 open ✓
13
+ * ▸ workspace-manager-security security/x #45 draft *2
14
+ *
15
+ * [pi's default footer renders below — untouched]
16
+ *
17
+ * Design choices:
18
+ * - Workspace name lives in the editor's top border (Claude-Code style "tag")
19
+ * - Status line + table sit BELOW the editor (user request)
20
+ * - Thinking level visible (`⚡ high`)
21
+ * - Column headers visible in `muted` (not `dim`) for legibility
22
+ * - Icons rendered uncolored (emojis show native colors), text rendered in
23
+ * semantic theme colors — no inverse/bg surprises
24
+ * - Column widths computed on plain text; OSC 8 + colors applied AFTER pad
25
+ *
26
+ * Filter: only repos with PR AND not on default branch. /wsrepos toggles full.
27
+ *
28
+ * Commands: /wsrepos /wsrefresh /wsopen
29
+ */
30
+
31
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
32
+ import { existsSync, readFileSync } from "node:fs";
33
+ import { homedir } from "node:os";
34
+ import { dirname, join } from "node:path";
35
+ import {
36
+ CustomEditor,
37
+ type ExtensionAPI,
38
+ type ExtensionContext,
39
+ type KeybindingsManager,
40
+ } from "@earendil-works/pi-coding-agent";
41
+ import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
42
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
43
+
44
+ // ─── Glyphs ─────────────────────────────────────────────────────────────────
45
+ // Status line uses universal emojis (work in all terminals & don't fight ANSI fg).
46
+ const G = {
47
+ model: "✦",
48
+ thinking: "⚡",
49
+ workspace: "📂",
50
+ folder: "📁",
51
+ context: "🧠",
52
+ cost: "💰",
53
+ clock: "⏱",
54
+ branch: "🌿",
55
+ activeMark: "▸",
56
+ // Repo table uses gh-dash-style Nerd Font glyphs for crisp PR/CI states.
57
+ prOpen: "", // nf-cod-git_pull_request
58
+ prClosed: "", // nf-cod-git_pull_request_closed
59
+ prMerged: "", // nf-oct-git_merge
60
+ prDraft: "", // nf-oct-git_pull_request_draft
61
+ ciOk: "✓",
62
+ ciFail: "✗",
63
+ ciPending: "◌",
64
+ commentIcon: "",
65
+ dirty: "*",
66
+ ahead: "↑",
67
+ behind: "↓",
68
+ };
69
+
70
+ const DEFAULT_BRANCHES = new Set([
71
+ "main",
72
+ "master",
73
+ "develop",
74
+ "production",
75
+ "trunk",
76
+ ]);
77
+
78
+ // ─── Workspace metadata ─────────────────────────────────────────────────────
79
+ type WorkspaceRepoMeta = {
80
+ name: string;
81
+ directoryName: string;
82
+ owner: string;
83
+ cloneUrl?: string;
84
+ };
85
+
86
+ type Workspace = {
87
+ workspaceName: string;
88
+ root: string;
89
+ repositories: WorkspaceRepoMeta[];
90
+ };
91
+
92
+ function findWorkspace(cwd: string): Workspace | undefined {
93
+ let dir = cwd;
94
+ for (let i = 0; i < 12; i++) {
95
+ const meta = join(dir, ".workspace-meta.json");
96
+ if (existsSync(meta)) {
97
+ try {
98
+ const data = JSON.parse(readFileSync(meta, "utf8")) as {
99
+ workspaceName?: string;
100
+ repositories?: WorkspaceRepoMeta[];
101
+ };
102
+ if (data.workspaceName && Array.isArray(data.repositories)) {
103
+ return {
104
+ workspaceName: data.workspaceName,
105
+ root: dir,
106
+ repositories: data.repositories,
107
+ };
108
+ }
109
+ } catch {
110
+ /* ignore malformed file */
111
+ }
112
+ }
113
+ const parent = dirname(dir);
114
+ if (parent === dir) break;
115
+ dir = parent;
116
+ }
117
+ return undefined;
118
+ }
119
+
120
+ // ─── Helpers ────────────────────────────────────────────────────────────────
121
+ function abbreviatePath(cwd: string): string {
122
+ const home = homedir();
123
+ let path = cwd;
124
+ if (path === home) return "~";
125
+ if (path.startsWith(`${home}/`)) path = `~${path.slice(home.length)}`;
126
+ const parts = path.split("/");
127
+ if (parts.length > 4) {
128
+ return [parts[0], "…", parts[parts.length - 2], parts[parts.length - 1]].join(
129
+ "/",
130
+ );
131
+ }
132
+ return path;
133
+ }
134
+
135
+ function shortModel(id: string | undefined): string {
136
+ if (!id) return "no model";
137
+ const tail = id.split(".").pop() ?? id;
138
+ return tail.replace(/^claude-/, "").replace(/-v\d+$/, "");
139
+ }
140
+
141
+ function osc8(url: string, text: string): string {
142
+ return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
143
+ }
144
+
145
+ function formatDuration(ms: number): string {
146
+ const s = Math.floor(ms / 1000);
147
+ const h = Math.floor(s / 3600);
148
+ const m = Math.floor((s % 3600) / 60);
149
+ if (h > 0) return `${h}h ${m}m`;
150
+ if (m > 0) return `${m}m`;
151
+ return `${s}s`;
152
+ }
153
+
154
+ function fmtTokens(n: number): string {
155
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
156
+ return String(n);
157
+ }
158
+
159
+ type ThemeApi = {
160
+ fg: (color: string, text: string) => string;
161
+ bg: (color: string, text: string) => string;
162
+ bold: (text: string) => string;
163
+ getBgAnsi: (color: string) => string;
164
+ };
165
+
166
+ function sessionStats(ctx: ExtensionContext): {
167
+ input: number;
168
+ output: number;
169
+ cost: number;
170
+ } {
171
+ let input = 0;
172
+ let output = 0;
173
+ let cost = 0;
174
+ for (const e of ctx.sessionManager.getBranch()) {
175
+ if (e.type === "message" && e.message.role === "assistant") {
176
+ const m = e.message as AssistantMessage;
177
+ input += m.usage.input ?? 0;
178
+ output += m.usage.output ?? 0;
179
+ cost += m.usage.cost?.total ?? 0;
180
+ }
181
+ }
182
+ return { input, output, cost };
183
+ }
184
+
185
+ /** Render a small "[ tag ]" pill on the editor's top border. */
186
+ function topBorderTag(theme: ThemeApi, label: string): string {
187
+ const fgKey = "borderAccent"; // pink in dracula → matches CC tag color
188
+ const bgKey = "selectedBg"; // currentLine in dracula → subtle frame
189
+ const bgAnsi = theme.getBgAnsi(bgKey);
190
+ const edgeFg = bgAnsi.replace(/\x1b\[48;/, "\x1b[38;");
191
+ const fgReset = "\x1b[39m";
192
+ const body = theme.bg(bgKey, ` ${theme.bold(theme.fg(fgKey, label))} `);
193
+ return `${edgeFg}\uE0B6${fgReset}${body}${edgeFg}\uE0B4${fgReset}`;
194
+ }
195
+
196
+ /** Place left- and right-aligned content on a horizontal border line. */
197
+ function fitBorder(
198
+ left: string,
199
+ right: string,
200
+ width: number,
201
+ border: (text: string) => string,
202
+ ): string {
203
+ if (width <= 0) return "";
204
+ if (width === 1) return border("─");
205
+ let leftText = left;
206
+ let rightText = right;
207
+ const fixed = 2;
208
+ const minGap = 1;
209
+ while (
210
+ fixed + visibleWidth(leftText) + visibleWidth(rightText) + minGap > width &&
211
+ visibleWidth(rightText) > 0
212
+ ) {
213
+ rightText = truncateToWidth(rightText, Math.max(0, visibleWidth(rightText) - 1), "");
214
+ }
215
+ while (
216
+ fixed + visibleWidth(leftText) + visibleWidth(rightText) + minGap > width &&
217
+ visibleWidth(leftText) > 0
218
+ ) {
219
+ leftText = truncateToWidth(leftText, Math.max(0, visibleWidth(leftText) - 1), "");
220
+ }
221
+ const gap = Math.max(
222
+ 0,
223
+ width - fixed - visibleWidth(leftText) - visibleWidth(rightText),
224
+ );
225
+ return `${border("─")}${leftText}${border("─".repeat(gap))}${rightText}${border("─")}`;
226
+ }
227
+
228
+ // ─── Repo state ─────────────────────────────────────────────────────────────
229
+ type CIStatus = "success" | "failure" | "pending" | "none";
230
+
231
+ type PR = {
232
+ number: number;
233
+ url: string;
234
+ state: "OPEN" | "MERGED" | "CLOSED";
235
+ isDraft: boolean;
236
+ commentCount: number;
237
+ ci: CIStatus;
238
+ };
239
+
240
+ type RepoState = WorkspaceRepoMeta & {
241
+ branch?: string;
242
+ branchUrl?: string;
243
+ pr?: PR;
244
+ dirty: number;
245
+ ahead: number;
246
+ behind: number;
247
+ loading: boolean;
248
+ };
249
+
250
+ // ─── Extension ──────────────────────────────────────────────────────────────
251
+ export default function (pi: ExtensionAPI) {
252
+ let isWorking = false;
253
+ let isShutdown = false; // set on session_shutdown to guard stale pi.exec() calls
254
+ let hasUiSession = false; // true only inside an interactive TUI session — gates ALL reactive work (agent_start/agent_end refreshes, timers)
255
+ let spinnerIdx = 0;
256
+ let spinnerTimer: ReturnType<typeof setInterval> | undefined;
257
+ let activeTui: TUI | undefined;
258
+ const spinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
259
+
260
+ const stopSpinner = () => {
261
+ if (spinnerTimer) {
262
+ clearInterval(spinnerTimer);
263
+ spinnerTimer = undefined;
264
+ }
265
+ };
266
+
267
+ let workspace: Workspace | undefined;
268
+ let repoStates: RepoState[] = [];
269
+ let showOnlyInteresting = true;
270
+ let cwd = process.cwd();
271
+ let currentBranch: string | undefined;
272
+ let currentBranchDirty = 0;
273
+ let sessionStartedAt = Date.now();
274
+ let sessionTickTimer: ReturnType<typeof setInterval> | undefined;
275
+
276
+ async function getAheadBehind(
277
+ dir: string,
278
+ ): Promise<{ ahead: number; behind: number }> {
279
+ for (const ref of ["origin/HEAD", "origin/main", "origin/master"]) {
280
+ const r = await pi
281
+ .exec(
282
+ "git",
283
+ ["rev-list", "--left-right", "--count", `${ref}...HEAD`],
284
+ { cwd: dir, timeout: 3000 },
285
+ )
286
+ .catch(() => undefined);
287
+ if (r?.code === 0) {
288
+ const [behindStr, aheadStr] = r.stdout.trim().split(/\s+/);
289
+ const behind = Number(behindStr ?? 0);
290
+ const ahead = Number(aheadStr ?? 0);
291
+ if (Number.isFinite(behind) && Number.isFinite(ahead))
292
+ return { ahead, behind };
293
+ }
294
+ }
295
+ return { ahead: 0, behind: 0 };
296
+ }
297
+
298
+ async function getRepoWebUrl(dir: string): Promise<string | undefined> {
299
+ const r = await pi
300
+ .exec("git", ["remote", "get-url", "origin"], { cwd: dir, timeout: 2000 })
301
+ .catch(() => undefined);
302
+ if (r?.code !== 0) return undefined;
303
+ let url = r.stdout.trim();
304
+ if (url.startsWith("git@")) {
305
+ const [host, path] = url.slice(4).split(":", 2);
306
+ url = `https://${host}/${path}`;
307
+ }
308
+ if (url.endsWith(".git")) url = url.slice(0, -4);
309
+ return url;
310
+ }
311
+
312
+ function rollupCi(rollup: unknown): CIStatus {
313
+ if (!Array.isArray(rollup) || rollup.length === 0) return "none";
314
+ let hasFail = false;
315
+ let hasPending = false;
316
+ for (const c of rollup as Array<Record<string, unknown>>) {
317
+ const state =
318
+ (c.state as string) ||
319
+ (c.conclusion as string) ||
320
+ (c.status as string) ||
321
+ "";
322
+ const s = state.toUpperCase();
323
+ if (s === "FAILURE" || s === "TIMED_OUT" || s === "CANCELLED" || s === "ERROR")
324
+ hasFail = true;
325
+ else if (
326
+ s === "PENDING" ||
327
+ s === "IN_PROGRESS" ||
328
+ s === "QUEUED" ||
329
+ s === "" ||
330
+ s === "EXPECTED"
331
+ )
332
+ hasPending = true;
333
+ }
334
+ if (hasFail) return "failure";
335
+ if (hasPending) return "pending";
336
+ return "success";
337
+ }
338
+
339
+ async function refreshRepo(state: RepoState): Promise<void> {
340
+ if (isShutdown || !workspace) return;
341
+ const dir = join(workspace.root, state.directoryName);
342
+ state.loading = true;
343
+ try {
344
+ const branchRes = await pi
345
+ .exec("git", ["branch", "--show-current"], { cwd: dir, timeout: 3000 })
346
+ .catch(() => undefined);
347
+ const branch = branchRes?.stdout.trim();
348
+ state.branch = branch && branch.length > 0 ? branch : undefined;
349
+ state.pr = undefined;
350
+
351
+ const [statusRes, ab, repoUrl, prRes] = await Promise.all([
352
+ pi
353
+ .exec("git", ["status", "--porcelain"], { cwd: dir, timeout: 3000 })
354
+ .catch(() => undefined),
355
+ getAheadBehind(dir),
356
+ getRepoWebUrl(dir),
357
+ state.branch
358
+ ? pi
359
+ .exec(
360
+ "gh",
361
+ [
362
+ "pr",
363
+ "list",
364
+ "--head",
365
+ state.branch,
366
+ "--state",
367
+ "all",
368
+ "--json",
369
+ "number,url,state,isDraft,statusCheckRollup,comments",
370
+ "--limit",
371
+ "1",
372
+ ],
373
+ { cwd: dir, timeout: 8000 },
374
+ )
375
+ .catch(() => undefined)
376
+ : Promise.resolve(undefined),
377
+ ]);
378
+
379
+ state.dirty = statusRes?.stdout
380
+ ? statusRes.stdout
381
+ .split("\n")
382
+ .filter((l: string) => l.trim().length > 0).length
383
+ : 0;
384
+ state.ahead = ab.ahead;
385
+ state.behind = ab.behind;
386
+ state.branchUrl =
387
+ repoUrl && state.branch ? `${repoUrl}/tree/${state.branch}` : undefined;
388
+
389
+ if (prRes?.code === 0 && prRes.stdout.trim()) {
390
+ try {
391
+ const arr = JSON.parse(prRes.stdout) as Array<{
392
+ number: number;
393
+ url: string;
394
+ state: PR["state"];
395
+ isDraft: boolean;
396
+ statusCheckRollup?: unknown;
397
+ comments?: unknown;
398
+ }>;
399
+ if (arr.length > 0) {
400
+ const raw = arr[0];
401
+ state.pr = {
402
+ number: raw.number,
403
+ url: raw.url,
404
+ state: raw.state,
405
+ isDraft: raw.isDraft,
406
+ ci: rollupCi(raw.statusCheckRollup),
407
+ commentCount: Array.isArray(raw.comments)
408
+ ? raw.comments.length
409
+ : 0,
410
+ };
411
+ }
412
+ } catch {
413
+ /* ignore */
414
+ }
415
+ }
416
+ } finally {
417
+ state.loading = false;
418
+ activeTui?.requestRender();
419
+ }
420
+ }
421
+
422
+ async function refreshAllRepos(): Promise<void> {
423
+ if (!workspace) return;
424
+ await Promise.all(repoStates.map(refreshRepo));
425
+ }
426
+
427
+ async function refreshCurrentBranchInfo(): Promise<void> {
428
+ if (isShutdown) return;
429
+ const r = await pi
430
+ .exec("git", ["branch", "--show-current"], { cwd, timeout: 3000 })
431
+ .catch(() => undefined);
432
+ const b = r?.stdout.trim();
433
+ currentBranch = b && b.length > 0 ? b : undefined;
434
+
435
+ const s = await pi
436
+ .exec("git", ["status", "--porcelain"], { cwd, timeout: 3000 })
437
+ .catch(() => undefined);
438
+ currentBranchDirty = s?.stdout
439
+ ? s.stdout.split("\n").filter((l: string) => l.trim().length > 0).length
440
+ : 0;
441
+ activeTui?.requestRender();
442
+ }
443
+
444
+ pi.on("agent_start", () => {
445
+ if (!hasUiSession) return;
446
+ isWorking = true;
447
+ stopSpinner();
448
+ spinnerTimer = setInterval(() => {
449
+ spinnerIdx = (spinnerIdx + 1) % spinnerFrames.length;
450
+ activeTui?.requestRender();
451
+ }, 80);
452
+ activeTui?.requestRender();
453
+ });
454
+
455
+ pi.on("agent_end", () => {
456
+ if (!hasUiSession) return;
457
+ isWorking = false;
458
+ stopSpinner();
459
+ activeTui?.requestRender();
460
+ void refreshAllRepos();
461
+ void refreshCurrentBranchInfo();
462
+ });
463
+
464
+ pi.on("session_shutdown", () => {
465
+ isShutdown = true;
466
+ hasUiSession = false;
467
+ stopSpinner();
468
+ if (sessionTickTimer) {
469
+ clearInterval(sessionTickTimer);
470
+ sessionTickTimer = undefined;
471
+ }
472
+ activeTui = undefined;
473
+ });
474
+
475
+ pi.registerCommand("wsrepos", {
476
+ description: "Toggle workspace repo widget: filtered ↔ full",
477
+ handler: async (_args, ctx) => {
478
+ showOnlyInteresting = !showOnlyInteresting;
479
+ activeTui?.requestRender();
480
+ ctx.ui.notify(
481
+ `Workspace widget: ${showOnlyInteresting ? "filtered (PR + non-default)" : "full"}`,
482
+ "info",
483
+ );
484
+ },
485
+ });
486
+
487
+ pi.registerCommand("wsrefresh", {
488
+ description: "Refresh workspace repo branches/PRs",
489
+ handler: async (_args, ctx) => {
490
+ ctx.ui.notify("Refreshing…", "info");
491
+ await Promise.all([refreshAllRepos(), refreshCurrentBranchInfo()]);
492
+ ctx.ui.notify("Refreshed", "info");
493
+ },
494
+ });
495
+
496
+ pi.registerCommand("wsopen", {
497
+ description: "Open a repo's PR (or branch) in browser. Usage: /wsopen [repo]",
498
+ handler: async (args, ctx) => {
499
+ if (!workspace) {
500
+ ctx.ui.notify("Not in a workspace", "warning");
501
+ return;
502
+ }
503
+ const query = (args ?? "").trim();
504
+ let target: RepoState | undefined;
505
+ if (!query) {
506
+ target = repoStates.find((r) => {
507
+ const p = join(workspace!.root, r.directoryName);
508
+ return cwd === p || cwd.startsWith(`${p}/`);
509
+ });
510
+ if (!target) {
511
+ ctx.ui.notify(
512
+ "Not inside a specific repo. Try: /wsopen <name>",
513
+ "warning",
514
+ );
515
+ return;
516
+ }
517
+ } else {
518
+ const q = query.toLowerCase();
519
+ target =
520
+ repoStates.find((r) => r.directoryName.toLowerCase() === q) ||
521
+ repoStates.find((r) => r.directoryName.toLowerCase().startsWith(q)) ||
522
+ repoStates.find((r) => r.directoryName.toLowerCase().includes(q));
523
+ }
524
+ if (!target) {
525
+ ctx.ui.notify(`No matching repo for "${query}"`, "warning");
526
+ return;
527
+ }
528
+ let url: string;
529
+ let kind: string;
530
+ if (target.pr) {
531
+ url = target.pr.url;
532
+ kind = `PR #${target.pr.number}`;
533
+ } else if (target.branchUrl) {
534
+ url = target.branchUrl;
535
+ kind = `branch ${target.branch}`;
536
+ } else {
537
+ url = `https://github.com/${target.owner}/${target.name}`;
538
+ kind = "repo";
539
+ }
540
+ const opener =
541
+ process.platform === "darwin"
542
+ ? "open"
543
+ : process.platform === "win32"
544
+ ? "start"
545
+ : "xdg-open";
546
+ const r = await pi
547
+ .exec(opener, [url], { timeout: 3000 })
548
+ .catch(() => ({ code: -1, stdout: "", stderr: "", killed: false }));
549
+ if (r.code === 0)
550
+ ctx.ui.notify(`Opened ${target.directoryName}: ${kind}`, "info");
551
+ else ctx.ui.notify(`Could not open ${url}`, "error");
552
+ },
553
+ });
554
+
555
+ pi.on("session_start", (_event, ctx) => {
556
+ // Skip the entire widget setup in non-TUI modes (pi -p, JSON mode, RPC).
557
+ // Without this, async refreshes launched below (refreshCurrentBranchInfo,
558
+ // refreshAllRepos) would still be in flight when the print-mode runtime
559
+ // tears down, causing pi.exec() to throw "extension ctx is stale" and
560
+ // crash the whole pi -p process. There is also no UI to render to in
561
+ // these modes, so the work is wasted.
562
+ if (!ctx.hasUI) return;
563
+ hasUiSession = true;
564
+
565
+ cwd = ctx.cwd;
566
+ workspace = findWorkspace(cwd);
567
+ repoStates = workspace
568
+ ? workspace.repositories.map((r) => ({
569
+ ...r,
570
+ loading: true,
571
+ dirty: 0,
572
+ ahead: 0,
573
+ behind: 0,
574
+ }))
575
+ : [];
576
+
577
+ sessionStartedAt = Date.now();
578
+ if (sessionTickTimer) clearInterval(sessionTickTimer);
579
+ sessionTickTimer = setInterval(() => activeTui?.requestRender(), 30_000);
580
+
581
+ void refreshCurrentBranchInfo();
582
+ void refreshAllRepos();
583
+
584
+ // ─── Custom editor: workspace pill on top border ──────────────────────
585
+ class BorderEditor extends CustomEditor {
586
+ constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) {
587
+ super(tui, theme, keybindings, { paddingX: 0 });
588
+ activeTui = tui;
589
+ }
590
+
591
+ render(width: number): string[] {
592
+ const lines = super.render(width);
593
+ if (lines.length < 2 || !workspace) return lines;
594
+ const thm = ctx.ui.theme as unknown as ThemeApi;
595
+ const borderColor = (text: string) => this.borderColor(text);
596
+
597
+ const tag = topBorderTag(thm, workspace.workspaceName);
598
+ lines[0] = fitBorder("", tag, width, borderColor);
599
+
600
+ // Safety: PowerLine glyphs (\uE0B6/\uE0B4) may have uncertain
601
+ // rendered width. Truncate every line to terminal width so Pi
602
+ // never crashes on resize.
603
+ return lines.map((line) =>
604
+ visibleWidth(line) > width
605
+ ? truncateToWidth(line, width, "")
606
+ : line
607
+ );
608
+ }
609
+ }
610
+
611
+ ctx.ui.setEditorComponent(
612
+ (tui, theme, keybindings) => new BorderEditor(tui, theme, keybindings),
613
+ );
614
+
615
+ // ─── BELOW-EDITOR widget: status line + table ─────────────────────────
616
+ ctx.ui.setWidget(
617
+ "workspace-input-status",
618
+ (tui) => {
619
+ activeTui = tui;
620
+ return {
621
+ render(width: number): string[] {
622
+ const thm = ctx.ui.theme as unknown as ThemeApi;
623
+ const raw: string[] = [];
624
+
625
+ raw.push(renderStatusLine(thm, ctx));
626
+
627
+ if (workspace && workspace.repositories.length > 0) {
628
+ raw.push("");
629
+ raw.push(...renderRepoTable(thm, width));
630
+ }
631
+
632
+ // Guarantee every line fits within the terminal width.
633
+ // Pi will crash with an uncaughtException if any line
634
+ // exceeds the available width.
635
+ return raw.map((line) =>
636
+ visibleWidth(line) > width
637
+ ? truncateToWidth(line, width, "")
638
+ : line
639
+ );
640
+ },
641
+ invalidate(): void {},
642
+ };
643
+ },
644
+ { placement: "belowEditor" },
645
+ );
646
+ });
647
+
648
+ // ─── Renderers ──────────────────────────────────────────────────────────
649
+ function renderStatusLine(thm: ThemeApi, ctx: ExtensionContext): string {
650
+ // Each segment: an emoji icon (rendered uncolored — emojis paint themselves)
651
+ // followed by the styled text.
652
+ const segs: string[] = [];
653
+
654
+ if (isWorking) {
655
+ segs.push(thm.fg("accent", spinnerFrames[spinnerIdx]));
656
+ }
657
+
658
+ // Model
659
+ segs.push(
660
+ `${G.model} ${thm.bold(thm.fg("borderAccent", shortModel(ctx.model?.id)))}`,
661
+ );
662
+
663
+ // Thinking
664
+ const thinking = pi.getThinkingLevel();
665
+ const thinkingLabel = thinking === "off" ? "off" : thinking;
666
+ const thinkColor =
667
+ thinking === "xhigh" || thinking === "high"
668
+ ? "warning"
669
+ : thinking === "off"
670
+ ? "dim"
671
+ : "muted";
672
+ segs.push(`${G.thinking} ${thm.fg(thinkColor, thinkingLabel)}`);
673
+
674
+ // Workspace OR path
675
+ if (workspace) {
676
+ const repoCount = workspace.repositories.length;
677
+ // Make the workspace name an OSC 8 hyperlink to its on-disk root, so
678
+ // clicking (cmd/ctrl-click in most terminals) opens the folder in Finder.
679
+ // encodeURI keeps the path separators but escapes spaces etc.; an empty
680
+ // host (file:///abs/path) is the portable form terminals open locally.
681
+ const wsLabel = thm.bold(
682
+ thm.fg("border", `ws:${workspace.workspaceName}`),
683
+ );
684
+ const wsLinked = osc8(`file://${encodeURI(workspace.root)}`, wsLabel);
685
+ segs.push(
686
+ `${G.workspace} ${wsLinked} ${thm.fg("dim", `(${repoCount})`)}`,
687
+ );
688
+ } else {
689
+ segs.push(`${G.folder} ${thm.fg("text", abbreviatePath(cwd))}`);
690
+ }
691
+
692
+ // Context
693
+ const usage = ctx.getContextUsage();
694
+ const pct = usage?.percent ?? null;
695
+ const window = usage?.contextWindow ?? ctx.model?.contextWindow ?? null;
696
+ if (pct !== null && window) {
697
+ const colorKey = pct < 50 ? "success" : pct < 80 ? "warning" : "error";
698
+ const used = fmtTokens(usage?.input ?? 0);
699
+ const max = fmtTokens(window);
700
+ segs.push(
701
+ `${G.context} ${thm.fg("muted", `${used}/${max}`)} ${thm.fg(colorKey, `${Math.round(pct)}%`)}`,
702
+ );
703
+ }
704
+
705
+ // Cost
706
+ const stats = sessionStats(ctx);
707
+ if (stats.cost > 0) {
708
+ segs.push(`${G.cost} ${thm.fg("success", `$${stats.cost.toFixed(2)}`)}`);
709
+ }
710
+
711
+ // Duration
712
+ segs.push(
713
+ `${G.clock} ${thm.fg("muted", formatDuration(Date.now() - sessionStartedAt))}`,
714
+ );
715
+
716
+ // Outside a workspace, surface current branch + dirty inline
717
+ if (!workspace && currentBranch) {
718
+ let s = `${G.branch} ${thm.fg("borderAccent", currentBranch)}`;
719
+ if (currentBranchDirty > 0)
720
+ s += ` ${thm.fg("error", `*${currentBranchDirty}`)}`;
721
+ segs.push(s);
722
+ }
723
+
724
+ return ` ${segs.join(" ")}`;
725
+ }
726
+
727
+ function renderRepoTable(thm: ThemeApi, maxWidth = 0): string[] {
728
+ if (!workspace) return [];
729
+
730
+ // Visibility filter: PR + non-default branch (toggle with /wsrepos)
731
+ const interesting = repoStates.filter((r) => {
732
+ if (r.loading && !r.branch) return true;
733
+ if (!r.branch) return false;
734
+ if (DEFAULT_BRANCHES.has(r.branch)) return false;
735
+ return !!r.pr;
736
+ });
737
+ const visible = showOnlyInteresting ? interesting : repoStates;
738
+
739
+ // Plain text builders for column width measurement
740
+ const trackingPlain = (r: RepoState): string => {
741
+ const parts: string[] = [];
742
+ if (r.behind > 0) parts.push(`${G.behind}${r.behind}`);
743
+ if (r.ahead > 0) parts.push(`${G.ahead}${r.ahead}`);
744
+ return parts.length > 0 ? ` ${parts.join(" ")}` : "";
745
+ };
746
+ const branchPlain = (r: RepoState): string =>
747
+ !r.branch ? "—" : `${r.branch}${trackingPlain(r)}`;
748
+ const prPlain = (r: RepoState): string => {
749
+ if (r.loading && !r.pr) return "…";
750
+ if (!r.pr) return r.branch ? "—" : "";
751
+ const stateLabel = r.pr.isDraft
752
+ ? "draft"
753
+ : r.pr.state === "OPEN"
754
+ ? "open"
755
+ : r.pr.state.toLowerCase();
756
+ const stateIcon = r.pr.isDraft
757
+ ? G.prDraft
758
+ : r.pr.state === "MERGED"
759
+ ? G.prMerged
760
+ : r.pr.state === "CLOSED"
761
+ ? G.prClosed
762
+ : G.prOpen;
763
+ const ci =
764
+ r.pr.ci === "success"
765
+ ? ` ${G.ciOk}`
766
+ : r.pr.ci === "failure"
767
+ ? ` ${G.ciFail}`
768
+ : r.pr.ci === "pending"
769
+ ? ` ${G.ciPending}`
770
+ : "";
771
+ const comments =
772
+ r.pr.commentCount > 0 ? ` ${G.commentIcon} ${r.pr.commentCount}` : "";
773
+ return `${stateIcon} #${r.pr.number} ${stateLabel}${ci}${comments}`;
774
+ };
775
+ const dirtyPlain = (r: RepoState): string =>
776
+ r.dirty > 0 ? `${G.dirty}${r.dirty}` : "";
777
+
778
+ // Headers (same width logic, treated as plain text)
779
+ const headers = { repo: "REPO", branch: "BRANCH", pr: "PR", dirty: "DIRTY" };
780
+
781
+ const repoW = Math.max(
782
+ headers.repo.length,
783
+ ...visible.map((r) => r.directoryName.length),
784
+ 14,
785
+ );
786
+ const branchW = Math.max(
787
+ headers.branch.length,
788
+ ...visible.map((r) => visibleWidth(branchPlain(r))),
789
+ 8,
790
+ );
791
+ const prW = Math.max(
792
+ headers.pr.length,
793
+ ...visible.map((r) => visibleWidth(prPlain(r))),
794
+ 8,
795
+ );
796
+ const dirtyW = Math.max(
797
+ headers.dirty.length,
798
+ ...visible.map((r) => dirtyPlain(r).length),
799
+ 0,
800
+ );
801
+
802
+ // Header line — using `muted` (not `dim`) so it's actually visible
803
+ const headerLine =
804
+ " " +
805
+ [
806
+ thm.fg("muted", padRightPlain(headers.repo, repoW)),
807
+ thm.fg("muted", padRightPlain(headers.branch, branchW)),
808
+ thm.fg("muted", padRightPlain(headers.pr, prW)),
809
+ thm.fg("muted", padRightPlain(headers.dirty, dirtyW)),
810
+ ].join(" ");
811
+
812
+ if (visible.length === 0) {
813
+ return [
814
+ headerLine,
815
+ ` ${thm.fg("dim", "no repos with open PRs (use /wsrepos to show all)")}`,
816
+ ];
817
+ }
818
+
819
+ const rows = visible.map((r) => {
820
+ const repoFullPath = join(workspace!.root, r.directoryName);
821
+ const isCurrent =
822
+ cwd === repoFullPath || cwd.startsWith(`${repoFullPath}/`);
823
+
824
+ // 2-cell active marker
825
+ const marker = isCurrent ? `${thm.fg("accent", G.activeMark)} ` : " ";
826
+
827
+ // REPO column — pad based on plain length, style after
828
+ const repoPlain = truncatePlain(r.directoryName, repoW);
829
+ const repoStyled = isCurrent
830
+ ? thm.bold(thm.fg("accent", repoPlain))
831
+ : thm.fg("mdLink", repoPlain);
832
+ const repoCell = repoStyled + " ".repeat(Math.max(0, repoW - repoPlain.length));
833
+
834
+ // BRANCH column
835
+ const branchPlainText = branchPlain(r);
836
+ let branchStyled: string;
837
+ if (!r.branch) {
838
+ branchStyled = thm.fg("dim", "—");
839
+ } else {
840
+ const onDefault = DEFAULT_BRANCHES.has(r.branch);
841
+ const nameStyled = onDefault
842
+ ? thm.fg("muted", r.branch)
843
+ : thm.fg("borderAccent", r.branch);
844
+ const linked = r.branchUrl ? osc8(r.branchUrl, nameStyled) : nameStyled;
845
+ const tracking = trackingPlain(r);
846
+ const trackingStyled = tracking ? thm.fg("dim", tracking) : "";
847
+ branchStyled = `${linked}${trackingStyled}`;
848
+ }
849
+ const branchPad = " ".repeat(
850
+ Math.max(0, branchW - visibleWidth(branchPlainText)),
851
+ );
852
+ const branchCell = `${branchStyled}${branchPad}`;
853
+
854
+ // PR column
855
+ const prPlainText = prPlain(r);
856
+ let prStyled: string;
857
+ if (r.loading && !r.pr) {
858
+ prStyled = thm.fg("dim", "…");
859
+ } else if (!r.pr) {
860
+ prStyled = r.branch ? thm.fg("dim", "—") : "";
861
+ } else {
862
+ const { state, isDraft, number, url, ci, commentCount } = r.pr;
863
+ const stateIcon = isDraft
864
+ ? G.prDraft
865
+ : state === "MERGED"
866
+ ? G.prMerged
867
+ : state === "CLOSED"
868
+ ? G.prClosed
869
+ : G.prOpen;
870
+ const stateColor = isDraft
871
+ ? "muted"
872
+ : state === "MERGED"
873
+ ? "accent"
874
+ : state === "CLOSED"
875
+ ? "error"
876
+ : "success";
877
+ const stateLabel = isDraft ? "draft" : state.toLowerCase();
878
+ const head = `${stateIcon} ${thm.fg(stateColor, `#${number} ${stateLabel}`)}`;
879
+ const ciStyled =
880
+ ci === "success"
881
+ ? ` ${thm.fg("success", G.ciOk)}`
882
+ : ci === "failure"
883
+ ? ` ${thm.fg("error", G.ciFail)}`
884
+ : ci === "pending"
885
+ ? ` ${thm.fg("warning", G.ciPending)}`
886
+ : "";
887
+ const commentBadge =
888
+ commentCount > 0
889
+ ? ` ${thm.fg("muted", `${G.commentIcon} ${commentCount}`)}`
890
+ : "";
891
+ const inner = `${head}${ciStyled}${commentBadge}`;
892
+ prStyled = osc8(url, inner);
893
+ }
894
+ const prPad = " ".repeat(Math.max(0, prW - visibleWidth(prPlainText)));
895
+ const prCell = `${prStyled}${prPad}`;
896
+
897
+ // DIRTY column
898
+ const dirtyText = dirtyPlain(r);
899
+ const dirtyStyled = r.dirty > 0 ? thm.fg("error", dirtyText) : "";
900
+ const dirtyPad = " ".repeat(Math.max(0, dirtyW - dirtyText.length));
901
+ const dirtyCell = `${dirtyStyled}${dirtyPad}`;
902
+
903
+ return `${marker}${repoCell} ${branchCell} ${prCell} ${dirtyCell}`.trimEnd();
904
+ });
905
+
906
+ return [headerLine, ...rows];
907
+ // Per-row truncation is handled by the widget's render() guard above.
908
+ }
909
+ }
910
+
911
+ function padRightPlain(text: string, width: number): string {
912
+ if (text.length >= width) return text;
913
+ return text + " ".repeat(width - text.length);
914
+ }
915
+
916
+ function truncatePlain(text: string, width: number): string {
917
+ if (text.length <= width) return text;
918
+ return `${text.slice(0, width - 1)}…`;
919
+ }