@doriandev/devcc 0.1.2 → 0.3.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 (151) hide show
  1. package/README.md +589 -389
  2. package/bin/devcc.mjs +50 -23
  3. package/dist/devcc/agents/agents.d.ts +119 -0
  4. package/dist/devcc/agents/agents.d.ts.map +1 -0
  5. package/dist/devcc/agents/agents.js +182 -0
  6. package/dist/devcc/agents/agents.js.map +1 -0
  7. package/dist/devcc/app/App.d.ts +23 -0
  8. package/dist/devcc/app/App.d.ts.map +1 -1
  9. package/dist/devcc/app/App.js +451 -19
  10. package/dist/devcc/app/App.js.map +1 -1
  11. package/dist/devcc/app/controlPlaneCommands.d.ts +14 -0
  12. package/dist/devcc/app/controlPlaneCommands.d.ts.map +1 -0
  13. package/dist/devcc/app/controlPlaneCommands.js +204 -0
  14. package/dist/devcc/app/controlPlaneCommands.js.map +1 -0
  15. package/dist/devcc/app/keymap.d.ts.map +1 -1
  16. package/dist/devcc/app/keymap.js +25 -3
  17. package/dist/devcc/app/keymap.js.map +1 -1
  18. package/dist/devcc/automation.d.ts +52 -0
  19. package/dist/devcc/automation.d.ts.map +1 -0
  20. package/dist/devcc/automation.js +339 -0
  21. package/dist/devcc/automation.js.map +1 -0
  22. package/dist/devcc/cli.d.ts.map +1 -1
  23. package/dist/devcc/cli.js +73 -3
  24. package/dist/devcc/cli.js.map +1 -1
  25. package/dist/devcc/components/Sidebar.d.ts.map +1 -1
  26. package/dist/devcc/components/Sidebar.js +1 -0
  27. package/dist/devcc/components/Sidebar.js.map +1 -1
  28. package/dist/devcc/components/primitives.d.ts.map +1 -1
  29. package/dist/devcc/components/primitives.js +3 -0
  30. package/dist/devcc/components/primitives.js.map +1 -1
  31. package/dist/devcc/core/commands.d.ts +33 -0
  32. package/dist/devcc/core/commands.d.ts.map +1 -1
  33. package/dist/devcc/core/commands.js.map +1 -1
  34. package/dist/devcc/core/config.d.ts +71 -3
  35. package/dist/devcc/core/config.d.ts.map +1 -1
  36. package/dist/devcc/core/config.js +189 -5
  37. package/dist/devcc/core/config.js.map +1 -1
  38. package/dist/devcc/core/state.d.ts +19 -1
  39. package/dist/devcc/core/state.d.ts.map +1 -1
  40. package/dist/devcc/core/state.js +6 -0
  41. package/dist/devcc/core/state.js.map +1 -1
  42. package/dist/devcc/environment/EnvironmentService.d.ts +58 -0
  43. package/dist/devcc/environment/EnvironmentService.d.ts.map +1 -0
  44. package/dist/devcc/environment/EnvironmentService.js +159 -0
  45. package/dist/devcc/environment/EnvironmentService.js.map +1 -0
  46. package/dist/devcc/environment/integrations.d.ts +52 -0
  47. package/dist/devcc/environment/integrations.d.ts.map +1 -0
  48. package/dist/devcc/environment/integrations.js +94 -0
  49. package/dist/devcc/environment/integrations.js.map +1 -0
  50. package/dist/devcc/environment/tools.d.ts +51 -0
  51. package/dist/devcc/environment/tools.d.ts.map +1 -0
  52. package/dist/devcc/environment/tools.js +99 -0
  53. package/dist/devcc/environment/tools.js.map +1 -0
  54. package/dist/devcc/git/GitService.d.ts +26 -0
  55. package/dist/devcc/git/GitService.d.ts.map +1 -1
  56. package/dist/devcc/git/GitService.js +71 -2
  57. package/dist/devcc/git/GitService.js.map +1 -1
  58. package/dist/devcc/git/worktrees.d.ts +88 -0
  59. package/dist/devcc/git/worktrees.d.ts.map +1 -0
  60. package/dist/devcc/git/worktrees.js +226 -0
  61. package/dist/devcc/git/worktrees.js.map +1 -0
  62. package/dist/devcc/index.d.ts +15 -3
  63. package/dist/devcc/index.d.ts.map +1 -1
  64. package/dist/devcc/index.js +14 -2
  65. package/dist/devcc/index.js.map +1 -1
  66. package/dist/devcc/init/detect.d.ts +55 -0
  67. package/dist/devcc/init/detect.d.ts.map +1 -0
  68. package/dist/devcc/init/detect.js +207 -0
  69. package/dist/devcc/init/detect.js.map +1 -0
  70. package/dist/devcc/init/prompt.d.ts +25 -0
  71. package/dist/devcc/init/prompt.d.ts.map +1 -0
  72. package/dist/devcc/init/prompt.js +156 -0
  73. package/dist/devcc/init/prompt.js.map +1 -0
  74. package/dist/devcc/platform/launch.d.ts +30 -0
  75. package/dist/devcc/platform/launch.d.ts.map +1 -0
  76. package/dist/devcc/platform/launch.js +93 -0
  77. package/dist/devcc/platform/launch.js.map +1 -0
  78. package/dist/devcc/services/ServiceManager.d.ts +10 -2
  79. package/dist/devcc/services/ServiceManager.d.ts.map +1 -1
  80. package/dist/devcc/services/ServiceManager.js +18 -4
  81. package/dist/devcc/services/ServiceManager.js.map +1 -1
  82. package/dist/devcc/services/node.d.ts +11 -0
  83. package/dist/devcc/services/node.d.ts.map +1 -1
  84. package/dist/devcc/services/node.js +20 -18
  85. package/dist/devcc/services/node.js.map +1 -1
  86. package/dist/devcc/services/ollama.d.ts +64 -5
  87. package/dist/devcc/services/ollama.d.ts.map +1 -1
  88. package/dist/devcc/services/ollama.js +118 -17
  89. package/dist/devcc/services/ollama.js.map +1 -1
  90. package/dist/devcc/services/remote.d.ts +90 -0
  91. package/dist/devcc/services/remote.d.ts.map +1 -0
  92. package/dist/devcc/services/remote.js +233 -0
  93. package/dist/devcc/services/remote.js.map +1 -0
  94. package/dist/devcc/services/types.d.ts +8 -1
  95. package/dist/devcc/services/types.d.ts.map +1 -1
  96. package/dist/devcc/services/types.js.map +1 -1
  97. package/dist/devcc/utils/exec.d.ts +6 -0
  98. package/dist/devcc/utils/exec.d.ts.map +1 -1
  99. package/dist/devcc/utils/exec.js +12 -2
  100. package/dist/devcc/utils/exec.js.map +1 -1
  101. package/dist/devcc/verify/verify.d.ts +107 -0
  102. package/dist/devcc/verify/verify.d.ts.map +1 -0
  103. package/dist/devcc/verify/verify.js +203 -0
  104. package/dist/devcc/verify/verify.js.map +1 -0
  105. package/dist/devcc/views/OverviewView.d.ts.map +1 -1
  106. package/dist/devcc/views/OverviewView.js +58 -2
  107. package/dist/devcc/views/OverviewView.js.map +1 -1
  108. package/dist/devcc/views/ServicesView.d.ts +2 -0
  109. package/dist/devcc/views/ServicesView.d.ts.map +1 -1
  110. package/dist/devcc/views/ServicesView.js +15 -0
  111. package/dist/devcc/views/ServicesView.js.map +1 -1
  112. package/dist/devcc/views/SystemView.d.ts +10 -1
  113. package/dist/devcc/views/SystemView.d.ts.map +1 -1
  114. package/dist/devcc/views/SystemView.js +127 -19
  115. package/dist/devcc/views/SystemView.js.map +1 -1
  116. package/dist/devcc/views/WorktreesView.d.ts +48 -0
  117. package/dist/devcc/views/WorktreesView.d.ts.map +1 -0
  118. package/dist/devcc/views/WorktreesView.js +235 -0
  119. package/dist/devcc/views/WorktreesView.js.map +1 -0
  120. package/package.json +1 -1
  121. package/src/devcc/agents/agents.ts +292 -0
  122. package/src/devcc/app/App.ts +529 -22
  123. package/src/devcc/app/controlPlaneCommands.ts +227 -0
  124. package/src/devcc/app/keymap.ts +25 -3
  125. package/src/devcc/automation.ts +409 -0
  126. package/src/devcc/cli.ts +77 -3
  127. package/src/devcc/components/Sidebar.ts +1 -0
  128. package/src/devcc/components/primitives.ts +3 -0
  129. package/src/devcc/core/commands.ts +34 -0
  130. package/src/devcc/core/config.ts +275 -11
  131. package/src/devcc/core/state.ts +25 -0
  132. package/src/devcc/environment/EnvironmentService.ts +215 -0
  133. package/src/devcc/environment/integrations.ts +133 -0
  134. package/src/devcc/environment/tools.ts +151 -0
  135. package/src/devcc/git/GitService.ts +104 -2
  136. package/src/devcc/git/worktrees.ts +256 -0
  137. package/src/devcc/index.ts +66 -2
  138. package/src/devcc/init/detect.ts +268 -0
  139. package/src/devcc/init/prompt.ts +179 -0
  140. package/src/devcc/platform/launch.ts +125 -0
  141. package/src/devcc/services/ServiceManager.ts +15 -3
  142. package/src/devcc/services/node.ts +17 -14
  143. package/src/devcc/services/ollama.ts +162 -19
  144. package/src/devcc/services/remote.ts +306 -0
  145. package/src/devcc/services/types.ts +9 -1
  146. package/src/devcc/utils/exec.ts +26 -2
  147. package/src/devcc/verify/verify.ts +316 -0
  148. package/src/devcc/views/OverviewView.ts +84 -2
  149. package/src/devcc/views/ServicesView.ts +17 -0
  150. package/src/devcc/views/SystemView.ts +144 -22
  151. package/src/devcc/views/WorktreesView.ts +270 -0
@@ -4,6 +4,7 @@ import { RowList, type ListRow } from "../components/primitives.js"
4
4
  import { processGlyph, serviceGlyph } from "../components/StatusBadge.js"
5
5
  import { chunk, dim, styled } from "../components/styled.js"
6
6
  import { summariseStatus, summariseTracking } from "../git/parse.js"
7
+ import { worktreeLabel } from "../git/worktrees.js"
7
8
  import { isActive } from "../processes/ManagedProcess.js"
8
9
  import type { AppState, ViewId } from "../core/state.js"
9
10
  import { fit, formatDuration, meter, toGigabytes } from "../utils/format.js"
@@ -51,13 +52,57 @@ export class OverviewView extends View {
51
52
  ),
52
53
  )
53
54
  rows.push(info("tracking", summariseTracking(state.git.status), labelWidth))
55
+ if (state.git.worktrees.length > 1) {
56
+ const current = state.git.worktrees.find((worktree) => worktree.isCurrent)
57
+ rows.push(
58
+ info(
59
+ "worktree",
60
+ `${current ? worktreeLabel(current) : "?"} · ${state.git.worktrees.length} worktrees (w)`,
61
+ labelWidth,
62
+ ),
63
+ )
64
+ }
65
+ }
66
+
67
+ // --- Verification ------------------------------------------------------
68
+ if (state.verify.configured > 0) {
69
+ rows.push(blank())
70
+ rows.push(section("Verify"))
71
+ rows.push(verifyRow(state, labelWidth))
72
+ }
73
+
74
+ // --- AI agents ---------------------------------------------------------
75
+ if (state.agents.length > 0) {
76
+ rows.push(blank())
77
+ rows.push(section("AI agents"))
78
+ for (const agent of state.agents.slice(0, compact ? 2 : 4)) {
79
+ rows.push({
80
+ key: `agent-${agent.id}`,
81
+ disabled: true,
82
+ content: styled(
83
+ " ",
84
+ processGlyph(agent.status).chunks[0] ?? chunk(" "),
85
+ " ",
86
+ chunk(fit(agent.agentName, 16), theme.text),
87
+ dim(fit(agent.branch ?? agent.cwd, 26)),
88
+ dim(fit(agent.task, 40)),
89
+ ),
90
+ })
91
+ }
54
92
  }
55
93
 
56
94
  // --- Development processes -------------------------------------------
57
95
  rows.push(blank())
58
96
  rows.push(section("Development"))
59
97
  if (state.processes.length === 0) {
60
- rows.push(info("", "no managed processes - add some in .devcc.ts"))
98
+ rows.push(
99
+ info(
100
+ "",
101
+ state.project.configPath
102
+ ? "no managed processes - add some in .devcc.ts"
103
+ : "no .devcc.ts yet - press / and run “Project: set up .devcc.ts”",
104
+ ),
105
+ )
61
106
  } else {
62
107
  for (const process of state.processes.slice(0, compact ? 4 : 8)) {
63
108
  const detail = process.url ?? (process.pid ? `pid ${process.pid}` : "")
@@ -135,6 +180,16 @@ export class OverviewView extends View {
135
180
  rows.push(info("node", `${state.project.host.runtime}`, labelWidth))
136
181
  }
137
182
  }
183
+ const attention = environmentAttention(state)
184
+ if (attention) {
185
+ rows.push(
186
+ infoStyled(
187
+ "environment",
188
+ styled(chunk(attention, theme.warning), dim(" (8 for details)")),
189
+ labelWidth,
190
+ ),
191
+ )
192
+ }
138
193
 
139
194
  this.list.setRows(rows, -1)
140
195
  }
@@ -144,7 +199,8 @@ export class OverviewView extends View {
144
199
  { keys: "↑↓", label: "navigate" },
145
200
  { keys: "p", label: "processes" },
146
201
  { keys: "g", label: "git" },
147
- { keys: "l", label: "logs" },
202
+ { keys: "w", label: "worktrees" },
203
+ { keys: "V", label: "verify" },
148
204
  { keys: "/", label: "commands" },
149
205
  { keys: "?", label: "help" },
150
206
  { keys: "q", label: "quit" },
@@ -180,6 +236,32 @@ function infoStyled(label: string, value: ReturnType<typeof styled>, labelWidth
180
236
  }
181
237
  }
182
238
 
239
+ function verifyRow(state: AppState, labelWidth: number): ListRow {
240
+ const { verify } = state
241
+ if (verify.steps.length === 0) {
242
+ return info("status", `${verify.configured} step(s) configured - press V to run`, labelWidth)
243
+ }
244
+ const passed = verify.steps.filter((step) => step.status === "passed").length
245
+ const failed = verify.steps.filter((step) => step.status === "failed")
246
+ const running = verify.steps.find((step) => step.status === "running")
247
+ const color = verify.running ? theme.warning : verify.ok ? theme.success : theme.danger
248
+ const summary = verify.running
249
+ ? `${passed}/${verify.steps.length} passed · running ${running?.name ?? "…"}`
250
+ : `${passed}/${verify.steps.length} passed${failed.length > 0 ? ` · failed: ${failed.map((step) => step.name).join(", ")}` : ""}`
251
+ return infoStyled("status", styled(chunk(summary, color)), labelWidth)
252
+ }
253
+
254
+ /** One line when the environment needs a look, otherwise nothing. */
255
+ function environmentAttention(state: AppState): string | null {
256
+ const { tools, direnv, mise } = state.environment
257
+ const problems = tools
258
+ .filter((tool) => tool.status === "error" || tool.status === "warning")
259
+ .map((tool) => tool.name)
260
+ if (mise.error) problems.push("mise")
261
+ if (direnv.installed && direnv.rcPath && direnv.approval !== "allowed") problems.push(".envrc not allowed")
262
+ return problems.length > 0 ? problems.join(", ") : null
263
+ }
264
+
183
265
  function usageColor(ratio: number): string {
184
266
  if (ratio > 0.85) return theme.danger
185
267
  if (ratio > 0.6) return theme.warning
@@ -5,11 +5,14 @@ import { RowList, type ListRow } from "../components/primitives.js"
5
5
  import { serviceBadge, serviceGlyph } from "../components/StatusBadge.js"
6
6
  import { chunk, dim, styled } from "../components/styled.js"
7
7
  import { setSelection, type AppState, type ViewId } from "../core/state.js"
8
+ import { remoteServiceId } from "../services/remote.js"
8
9
  import type { ServiceSnapshot } from "../services/types.js"
9
10
  import { errorMessage } from "../utils/errors.js"
10
11
  import { fit } from "../utils/format.js"
11
12
  import { View, type ViewDeps } from "./View.js"
12
13
 
14
+ const REMOTE_PREFIX = remoteServiceId("")
15
+
13
16
  interface Entry {
14
17
  kind: "service" | "item" | "heading"
15
18
  service?: ServiceSnapshot
@@ -117,6 +120,9 @@ export class ServicesView extends View {
117
120
  case "d":
118
121
  void this.composeDown(selected)
119
122
  return true
123
+ case "o":
124
+ void this.ssh(selected)
125
+ return true
120
126
  default:
121
127
  return false
122
128
  }
@@ -128,6 +134,7 @@ export class ServicesView extends View {
128
134
  { keys: "s", label: "start/stop" },
129
135
  { keys: "r", label: "restart" },
130
136
  { keys: "d", label: "compose down" },
137
+ { keys: "o", label: "ssh (remote)" },
131
138
  { keys: "/", label: "commands" },
132
139
  ]
133
140
  }
@@ -176,6 +183,16 @@ export class ServicesView extends View {
176
183
  }
177
184
  }
178
185
 
186
+ /** Open an SSH session to a remote machine in a terminal. */
187
+ private async ssh(index: number): Promise<void> {
188
+ const service = this.selectedService(index)
189
+ if (!service?.id.startsWith(REMOTE_PREFIX)) {
190
+ this.deps.app.notify("warn", "Select a remote machine first")
191
+ return
192
+ }
193
+ await this.deps.app.sshTo(service.id.slice(REMOTE_PREFIX.length))
194
+ }
195
+
179
196
  /** `docker compose down` removes containers; always confirmed. */
180
197
  private async composeDown(index: number): Promise<void> {
181
198
  const service = this.selectedService(index)
@@ -1,17 +1,28 @@
1
+ import type { KeyEvent } from "@opentui/core"
1
2
  import { theme } from "../app/theme.js"
2
3
  import type { KeyHint } from "../components/Footer.js"
3
4
  import { RowList, type ListRow } from "../components/primitives.js"
4
5
  import { chunk, dim, styled } from "../components/styled.js"
5
6
  import type { AppState, ViewId } from "../core/state.js"
7
+ import type { DirenvApproval } from "../environment/integrations.js"
8
+ import type { ToolStatus } from "../environment/tools.js"
6
9
  import { fit, formatBytes, formatDuration, meter, toGigabytes } from "../utils/format.js"
10
+ import { tildify } from "../utils/paths.js"
7
11
  import { View, type ViewDeps } from "./View.js"
8
12
 
9
- /** Host information and live resource usage. Read-only. */
13
+ /**
14
+ * Environment health, live resource usage and host information.
15
+ *
16
+ * Tool, mise and direnv detection runs once in the background and on request
17
+ * ("Environment: refresh" in the palette); nothing here polls external tools.
18
+ */
10
19
  export class SystemView extends View {
11
20
  readonly id: ViewId = "system"
12
21
  readonly title = "System"
13
22
 
14
23
  private readonly list: RowList
24
+ /** Kept across refreshes: metrics repaint every tick and must not reset the scroll. */
25
+ private offset = 0
15
26
 
16
27
  constructor(deps: ViewDeps) {
17
28
  super(deps)
@@ -21,28 +32,72 @@ export class SystemView extends View {
21
32
 
22
33
  update(state: AppState): void {
23
34
  const host = state.project.host
24
- const tools = state.project.tools
25
35
  const metrics = state.system
36
+ const environment = state.environment
26
37
  const rows: ListRow[] = []
27
38
 
28
- rows.push(heading("Host"))
29
- rows.push(row("hostname", host.hostname))
30
- rows.push(row("os", `${host.platform} ${host.release}`))
31
- rows.push(row("arch", host.arch))
32
- rows.push(row("cpu", `${host.cpuModel} (${host.cpuCount} cores)`))
33
- rows.push(row("memory", `${toGigabytes(host.totalMemory)} GB total`))
39
+ rows.push(heading("Environment"))
40
+ if (!environment.checkedAt) {
41
+ rows.push(row("", "checking tools…"))
42
+ } else {
43
+ for (const tool of environment.tools) rows.push(toolRow(tool))
44
+ if (environment.checking) rows.push(row("", "re-checking…"))
45
+ }
46
+
47
+ const { mise, direnv } = environment
48
+ if (mise.installed) {
49
+ rows.push(blank("mise"))
50
+ rows.push(heading("Runtimes (mise)"))
51
+ if (mise.error) rows.push(warningRow("mise", mise.error))
52
+ else if (mise.runtimes.length === 0) rows.push(row("", "no tools pinned for this project"))
53
+ for (const runtime of mise.runtimes) {
54
+ const note = [
55
+ runtime.requested && runtime.requested !== runtime.version
56
+ ? `requested ${runtime.requested}`
57
+ : undefined,
58
+ runtime.installed ? undefined : "not installed - run `mise install`",
59
+ ]
60
+ .filter(Boolean)
61
+ .join(" · ")
62
+ rows.push({
63
+ key: `rt-${runtime.tool}-${runtime.version}`,
64
+ disabled: true,
65
+ content: styled(
66
+ dim(fit(runtime.tool, LABEL_WIDTH)),
67
+ chunk(fit(runtime.version, 14), runtime.installed ? theme.text : theme.warning),
68
+ dim(note),
69
+ ),
70
+ })
71
+ }
72
+ }
73
+
74
+ if (direnv.installed || direnv.rcPath) {
75
+ rows.push(blank("direnv"))
76
+ rows.push(heading("direnv"))
77
+ if (direnv.error) rows.push(warningRow("status", direnv.error))
78
+ if (!direnv.rcPath) {
79
+ rows.push(row(".envrc", "none for this project"))
80
+ } else {
81
+ rows.push(row(".envrc", tildify(direnv.rcPath)))
82
+ if (direnv.installed) {
83
+ const approval = APPROVAL_LABELS[direnv.approval]
84
+ rows.push({
85
+ key: "direnv-approval",
86
+ disabled: true,
87
+ content: styled(
88
+ dim(fit("approval", LABEL_WIDTH)),
89
+ chunk(approval, direnv.approval === "allowed" ? theme.success : theme.warning),
90
+ direnv.approval === "allowed" ? dim("") : dim(" palette: Environment: direnv allow"),
91
+ ),
92
+ })
93
+ rows.push(
94
+ row("loaded in devcc", direnv.loaded ? "yes" : "no - start devcc from a direnv-enabled shell"),
95
+ )
96
+ }
97
+ }
98
+ }
34
99
 
35
- rows.push(blank())
36
- rows.push(heading("Toolchain"))
37
- rows.push(row("runtime", host.runtime))
38
- rows.push(row("node", host.nodeVersion))
39
- rows.push(
40
- row("package manager", `${state.project.packageManager.manager} ${tools.packageManager ?? ""}`.trim()),
41
- )
42
- rows.push(row("git", tools.git ?? "not found"))
43
- rows.push(row("docker", tools.docker ?? "not found"))
44
-
45
- rows.push(blank())
100
+ rows.push(blank("usage"))
46
101
  rows.push(heading("Usage"))
47
102
  if (!metrics) {
48
103
  rows.push(row("", "sampling…"))
@@ -67,15 +122,46 @@ export class SystemView extends View {
67
122
  rows.push(row("load average", metrics.loadAverage.map((value) => value.toFixed(2)).join(" ")))
68
123
  rows.push(row("host uptime", formatDuration(metrics.uptimeMs)))
69
124
  rows.push(row("devcc uptime", formatDuration(metrics.processUptimeMs)))
70
- rows.push(row("sampled", metrics.sampledAt.toLocaleTimeString()))
71
125
  }
72
126
 
127
+ rows.push(blank("host"))
128
+ rows.push(heading("Host"))
129
+ rows.push(row("hostname", host.hostname))
130
+ rows.push(row("os", `${host.platform} ${host.release}`))
131
+ rows.push(row("cpu", `${host.cpuModel} (${host.cpuCount} cores)`))
132
+ rows.push(row("memory", `${toGigabytes(host.totalMemory)} GB total`))
133
+ rows.push(row("devcc runtime", host.runtime))
134
+ rows.push(row("package manager", state.project.packageManager.manager))
135
+
73
136
  this.list.setRows(rows, -1)
137
+ if (this.offset > 0) this.list.scrollBy(this.offset)
138
+ this.offset = this.list.offset
139
+ }
140
+
141
+ override handleKey(key: KeyEvent): boolean {
142
+ switch (key.name) {
143
+ case "up":
144
+ case "k":
145
+ this.list.scrollBy(-1)
146
+ this.offset = this.list.offset
147
+ return true
148
+ case "down":
149
+ case "j":
150
+ this.list.scrollBy(1)
151
+ this.offset = this.list.offset
152
+ return true
153
+ case "r":
154
+ void this.deps.commands.run("environment.refresh", this.deps.app)
155
+ return true
156
+ default:
157
+ return false
158
+ }
74
159
  }
75
160
 
76
161
  override hints(): KeyHint[] {
77
162
  return [
78
163
  { keys: "↑↓", label: "scroll" },
164
+ { keys: "r", label: "re-check environment" },
79
165
  { keys: "/", label: "commands" },
80
166
  { keys: "?", label: "help" },
81
167
  ]
@@ -88,8 +174,44 @@ function heading(title: string): ListRow {
88
174
  return { key: `h-${title}`, disabled: true, content: styled(chunk(title.toUpperCase(), theme.accent)) }
89
175
  }
90
176
 
91
- function blank(): ListRow {
92
- return { key: `b-${Math.random().toString(36).slice(2)}`, disabled: true, content: "" }
177
+ function blank(id: string): ListRow {
178
+ return { key: `b-${id}`, disabled: true, content: "" }
179
+ }
180
+
181
+ const APPROVAL_LABELS: Record<DirenvApproval, string> = {
182
+ allowed: "allowed",
183
+ "not-allowed": "not allowed yet",
184
+ denied: "denied",
185
+ unknown: "unknown",
186
+ }
187
+
188
+ function toolRow(tool: ToolStatus): ListRow {
189
+ const mark = !tool.installed
190
+ ? chunk("·", theme.textDim)
191
+ : tool.status === "ready"
192
+ ? chunk("✓", theme.success)
193
+ : tool.status === "warning"
194
+ ? chunk("⚠", theme.warning)
195
+ : chunk("✖", theme.danger)
196
+ const value = tool.installed ? (tool.version ?? "installed") : "not installed"
197
+ return {
198
+ key: `tool-${tool.id}`,
199
+ disabled: true,
200
+ content: styled(
201
+ dim(fit(tool.name, LABEL_WIDTH)),
202
+ chunk(fit(value, 16), tool.installed ? theme.text : theme.textDim),
203
+ mark,
204
+ tool.message && tool.installed ? chunk(` ${tool.message}`, theme.warning) : dim(""),
205
+ ),
206
+ }
207
+ }
208
+
209
+ function warningRow(label: string, message: string): ListRow {
210
+ return {
211
+ key: `warn-${label}`,
212
+ disabled: true,
213
+ content: styled(dim(fit(label, LABEL_WIDTH)), chunk(`⚠ ${message}`, theme.warning)),
214
+ }
93
215
  }
94
216
 
95
217
  function row(label: string, value: string): ListRow {
@@ -0,0 +1,270 @@
1
+ import type { KeyEvent } from "@opentui/core"
2
+ import type { AgentSession } from "../agents/agents.js"
3
+ import { theme } from "../app/theme.js"
4
+ import type { KeyHint } from "../components/Footer.js"
5
+ import { RowList, type ListRow } from "../components/primitives.js"
6
+ import { processBadge } from "../components/StatusBadge.js"
7
+ import { chunk, dim, styled } from "../components/styled.js"
8
+ import { setSelection, updateUi, type AppState, type ViewId } from "../core/state.js"
9
+ import { isInside, worktreeLabel, type GitWorktree } from "../git/worktrees.js"
10
+ import { isActive } from "../processes/ManagedProcess.js"
11
+ import { errorMessage } from "../utils/errors.js"
12
+ import { fit, formatDuration } from "../utils/format.js"
13
+ import { tildify } from "../utils/paths.js"
14
+ import { View, type ViewDeps } from "./View.js"
15
+
16
+ export interface WorktreeEntry {
17
+ kind: "heading" | "blank" | "note" | "worktree" | "agent"
18
+ key: string
19
+ text?: string
20
+ worktree?: GitWorktree
21
+ agent?: AgentSession
22
+ }
23
+
24
+ /**
25
+ * The rows of the Worktrees view, derived from state alone so commands can
26
+ * resolve "the selected worktree" without asking the view.
27
+ */
28
+ export function worktreeEntries(state: AppState): WorktreeEntry[] {
29
+ const entries: WorktreeEntry[] = [{ kind: "heading", key: "h-worktrees", text: "Worktrees" }]
30
+
31
+ if (!state.git.isRepository) {
32
+ entries.push({
33
+ kind: "note",
34
+ key: "n-repo",
35
+ text: state.git.lastRefreshedAt ? "Not a git repository." : "Reading git…",
36
+ })
37
+ } else {
38
+ for (const worktree of state.git.worktrees) {
39
+ entries.push({ kind: "worktree", key: `w-${worktree.path}`, worktree })
40
+ }
41
+ }
42
+
43
+ entries.push({ kind: "blank", key: "b-agents" })
44
+ entries.push({ kind: "heading", key: "h-agents", text: "AI agents" })
45
+ if (state.agents.length === 0) {
46
+ entries.push({
47
+ kind: "note",
48
+ key: "n-agents",
49
+ text: "No agents started. Select a worktree and press c (Claude) or x (Codex).",
50
+ })
51
+ } else {
52
+ for (const agent of state.agents) entries.push({ kind: "agent", key: `a-${agent.id}`, agent })
53
+ }
54
+ return entries
55
+ }
56
+
57
+ function selectable(entry: WorktreeEntry | undefined): boolean {
58
+ return entry?.kind === "worktree" || entry?.kind === "agent"
59
+ }
60
+
61
+ export function selectedWorktreeEntry(state: AppState): WorktreeEntry | undefined {
62
+ const entry = worktreeEntries(state)[state.ui.selection.worktrees]
63
+ return selectable(entry) ? entry : undefined
64
+ }
65
+
66
+ /**
67
+ * Git worktrees and the AI agents working in them.
68
+ *
69
+ * Keys act on the highlighted row: worktree actions on a worktree, stop/logs
70
+ * on an agent session. Everything here is also in the palette.
71
+ */
72
+ export class WorktreesView extends View {
73
+ readonly id: ViewId = "worktrees"
74
+ readonly title = "Worktrees"
75
+
76
+ private readonly list: RowList
77
+ private entries: WorktreeEntry[] = []
78
+ /**
79
+ * The highlighted row's key. Rows shift when worktrees or agents appear, so
80
+ * the selection follows the row, not its index - `s` must never stop the
81
+ * wrong agent.
82
+ */
83
+ private selectedKey: string | undefined
84
+
85
+ constructor(deps: ViewDeps) {
86
+ super(deps)
87
+ this.list = new RowList(deps.ctx, { flexGrow: 1 })
88
+ this.box.add(this.list.box)
89
+ }
90
+
91
+ update(state: AppState): void {
92
+ this.entries = worktreeEntries(state)
93
+ const width = Math.max(40, state.ui.width - (state.ui.sidebarVisible ? 26 : 4))
94
+ const labelWidth = Math.min(30, Math.max(14, Math.floor(width * 0.3)))
95
+
96
+ let selected = state.ui.selection.worktrees
97
+ const followed = this.selectedKey ? this.entries.findIndex((entry) => entry.key === this.selectedKey) : -1
98
+ if (followed !== -1) selected = followed
99
+ else if (!selectable(this.entries[selected])) selected = this.entries.findIndex(selectable)
100
+ if (selected !== -1 && selected !== state.ui.selection.worktrees) {
101
+ setSelection(this.deps.app.store, "worktrees", selected)
102
+ }
103
+ this.selectedKey = this.entries[selected]?.key
104
+
105
+ const rows: ListRow[] = this.entries.map((entry) => {
106
+ switch (entry.kind) {
107
+ case "heading":
108
+ return {
109
+ key: entry.key,
110
+ disabled: true,
111
+ content: styled(chunk((entry.text ?? "").toUpperCase(), theme.accent)),
112
+ }
113
+ case "blank":
114
+ return { key: entry.key, disabled: true, content: "" }
115
+ case "note":
116
+ return { key: entry.key, disabled: true, content: styled(dim(` ${entry.text ?? ""}`)) }
117
+ case "worktree":
118
+ return {
119
+ key: entry.key,
120
+ content: this.worktreeRow(entry.worktree as GitWorktree, state, labelWidth, width),
121
+ }
122
+ case "agent":
123
+ return { key: entry.key, content: this.agentRow(entry.agent as AgentSession, width) }
124
+ }
125
+ })
126
+
127
+ this.list.setRows(rows, selected)
128
+ }
129
+
130
+ private worktreeRow(worktree: GitWorktree, state: AppState, labelWidth: number, width: number) {
131
+ const agents = state.agents.filter(
132
+ (agent) => isActive(agent.status) && isInside(agent.cwd, worktree.path),
133
+ )
134
+ const flags = [
135
+ worktree.isMain ? "main" : undefined,
136
+ worktree.locked ? "locked" : undefined,
137
+ worktree.prunable ? "prunable" : undefined,
138
+ ].filter(Boolean)
139
+ const pathWidth = Math.max(10, width - labelWidth - 24)
140
+
141
+ return styled(
142
+ chunk(worktree.isCurrent ? "● " : "○ ", worktree.isCurrent ? theme.accent : theme.textDim),
143
+ chunk(fit(worktreeLabel(worktree), labelWidth), worktree.isCurrent ? theme.textBright : theme.text),
144
+ dim(fit(tildify(worktree.path), pathWidth)),
145
+ agents.length > 0
146
+ ? chunk(fit(` ${agents.length} agent${agents.length === 1 ? "" : "s"} ▸`, 12), theme.success)
147
+ : dim(fit("", 12)),
148
+ worktree.locked || worktree.prunable ? chunk(flags.join(" "), theme.warning) : dim(flags.join(" ")),
149
+ )
150
+ }
151
+
152
+ private agentRow(agent: AgentSession, width: number) {
153
+ const where = agent.branch ?? tildify(agent.cwd)
154
+ const elapsed = isActive(agent.status)
155
+ ? formatDuration(Date.now() - agent.startedAt.getTime())
156
+ : agent.exitCode !== undefined
157
+ ? `exit ${agent.exitCode}`
158
+ : ""
159
+ return styled(
160
+ ...processBadge(agent.status).chunks,
161
+ " ",
162
+ chunk(fit(agent.agentName, 13), theme.textBright),
163
+ chunk(fit(where, 24), theme.text),
164
+ dim(fit(agent.task, Math.max(10, width - 64))),
165
+ dim(elapsed),
166
+ )
167
+ }
168
+
169
+ override handleKey(key: KeyEvent, state: AppState): boolean {
170
+ const index = state.ui.selection.worktrees
171
+ const entry = this.entries[index]
172
+ const worktree = entry?.worktree
173
+ const agent = entry?.agent
174
+ const app = this.deps.app
175
+
176
+ switch (key.name) {
177
+ case "up":
178
+ case "k":
179
+ this.move(index, -1)
180
+ return true
181
+ case "down":
182
+ case "j":
183
+ this.move(index, 1)
184
+ return true
185
+ case "n":
186
+ void app.createWorktree()
187
+ return true
188
+ case "r":
189
+ void app.git.refresh().then(() => app.notify("info", "Worktrees refreshed"))
190
+ return true
191
+ case "d":
192
+ if (worktree) void app.removeWorktree(worktree)
193
+ else app.notify("warn", "Select a worktree to remove")
194
+ return true
195
+ case "o":
196
+ case "return":
197
+ if (agent) this.showLogs(agent)
198
+ else if (worktree) void this.openProject(worktree)
199
+ return true
200
+ case "t":
201
+ void app.openInTerminal(worktree?.path ?? agent?.cwd ?? state.project.rootDir)
202
+ return true
203
+ case "e":
204
+ void app.openInEditor(worktree?.path ?? agent?.cwd ?? state.project.rootDir)
205
+ return true
206
+ case "c":
207
+ void app.launchAgent("claude", worktree)
208
+ return true
209
+ case "x":
210
+ void app.launchAgent("codex", worktree)
211
+ return true
212
+ case "s":
213
+ if (agent && isActive(agent.status)) void this.stopAgent(agent)
214
+ else if (agent) app.notify("info", `${agent.agentName} is not running`)
215
+ return true
216
+ case "l":
217
+ if (agent) {
218
+ this.showLogs(agent)
219
+ return true
220
+ }
221
+ return false
222
+ default:
223
+ return false
224
+ }
225
+ }
226
+
227
+ override hints(): KeyHint[] {
228
+ return [
229
+ { keys: "↑↓", label: "select" },
230
+ { keys: "n", label: "new" },
231
+ { keys: "d", label: "remove" },
232
+ { keys: "o", label: "open" },
233
+ { keys: "t/e", label: "terminal/editor" },
234
+ { keys: "c/x", label: "claude/codex" },
235
+ { keys: "s", label: "stop agent" },
236
+ { keys: "r", label: "refresh" },
237
+ ]
238
+ }
239
+
240
+ private move(from: number, direction: 1 | -1): void {
241
+ for (let index = from + direction; index >= 0 && index < this.entries.length; index += direction) {
242
+ if (selectable(this.entries[index])) {
243
+ this.selectedKey = this.entries[index]?.key
244
+ setSelection(this.deps.app.store, "worktrees", index)
245
+ return
246
+ }
247
+ }
248
+ }
249
+
250
+ private async openProject(worktree: GitWorktree): Promise<void> {
251
+ if (worktree.isCurrent) {
252
+ this.deps.app.notify("info", "devcc is already showing this worktree")
253
+ return
254
+ }
255
+ await this.deps.app.switchProject(worktree.path)
256
+ }
257
+
258
+ private showLogs(agent: AgentSession): void {
259
+ updateUi(this.deps.app.store, { logSource: agent.id, view: "logs" })
260
+ }
261
+
262
+ private async stopAgent(agent: AgentSession): Promise<void> {
263
+ try {
264
+ await this.deps.app.agents.stop(agent.id)
265
+ this.deps.app.notify("info", `Stopped ${agent.agentName}`)
266
+ } catch (error) {
267
+ this.deps.app.notify("error", errorMessage(error))
268
+ }
269
+ }
270
+ }