@doriandev/devcc 0.1.2 → 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 (136) hide show
  1. package/README.md +556 -386
  2. package/bin/devcc.mjs +50 -23
  3. package/dist/devcc/agents/agents.d.ts +112 -0
  4. package/dist/devcc/agents/agents.d.ts.map +1 -0
  5. package/dist/devcc/agents/agents.js +179 -0
  6. package/dist/devcc/agents/agents.js.map +1 -0
  7. package/dist/devcc/app/App.d.ts +15 -0
  8. package/dist/devcc/app/App.d.ts.map +1 -1
  9. package/dist/devcc/app/App.js +326 -17
  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 +179 -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 +24 -3
  17. package/dist/devcc/app/keymap.js.map +1 -1
  18. package/dist/devcc/automation.d.ts +31 -0
  19. package/dist/devcc/automation.d.ts.map +1 -0
  20. package/dist/devcc/automation.js +202 -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 +46 -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 +25 -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 +69 -3
  35. package/dist/devcc/core/config.d.ts.map +1 -1
  36. package/dist/devcc/core/config.js +185 -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 +13 -3
  63. package/dist/devcc/index.d.ts.map +1 -1
  64. package/dist/devcc/index.js +11 -1
  65. package/dist/devcc/index.js.map +1 -1
  66. package/dist/devcc/platform/launch.d.ts +30 -0
  67. package/dist/devcc/platform/launch.d.ts.map +1 -0
  68. package/dist/devcc/platform/launch.js +93 -0
  69. package/dist/devcc/platform/launch.js.map +1 -0
  70. package/dist/devcc/services/ServiceManager.d.ts +10 -2
  71. package/dist/devcc/services/ServiceManager.d.ts.map +1 -1
  72. package/dist/devcc/services/ServiceManager.js +18 -4
  73. package/dist/devcc/services/ServiceManager.js.map +1 -1
  74. package/dist/devcc/services/ollama.d.ts +64 -5
  75. package/dist/devcc/services/ollama.d.ts.map +1 -1
  76. package/dist/devcc/services/ollama.js +118 -17
  77. package/dist/devcc/services/ollama.js.map +1 -1
  78. package/dist/devcc/services/remote.d.ts +90 -0
  79. package/dist/devcc/services/remote.d.ts.map +1 -0
  80. package/dist/devcc/services/remote.js +233 -0
  81. package/dist/devcc/services/remote.js.map +1 -0
  82. package/dist/devcc/services/types.d.ts +8 -1
  83. package/dist/devcc/services/types.d.ts.map +1 -1
  84. package/dist/devcc/services/types.js.map +1 -1
  85. package/dist/devcc/utils/exec.d.ts +6 -0
  86. package/dist/devcc/utils/exec.d.ts.map +1 -1
  87. package/dist/devcc/utils/exec.js +12 -2
  88. package/dist/devcc/utils/exec.js.map +1 -1
  89. package/dist/devcc/verify/verify.d.ts +107 -0
  90. package/dist/devcc/verify/verify.d.ts.map +1 -0
  91. package/dist/devcc/verify/verify.js +203 -0
  92. package/dist/devcc/verify/verify.js.map +1 -0
  93. package/dist/devcc/views/OverviewView.d.ts.map +1 -1
  94. package/dist/devcc/views/OverviewView.js +55 -1
  95. package/dist/devcc/views/OverviewView.js.map +1 -1
  96. package/dist/devcc/views/ServicesView.d.ts +2 -0
  97. package/dist/devcc/views/ServicesView.d.ts.map +1 -1
  98. package/dist/devcc/views/ServicesView.js +15 -0
  99. package/dist/devcc/views/ServicesView.js.map +1 -1
  100. package/dist/devcc/views/SystemView.d.ts +10 -1
  101. package/dist/devcc/views/SystemView.d.ts.map +1 -1
  102. package/dist/devcc/views/SystemView.js +127 -19
  103. package/dist/devcc/views/SystemView.js.map +1 -1
  104. package/dist/devcc/views/WorktreesView.d.ts +48 -0
  105. package/dist/devcc/views/WorktreesView.d.ts.map +1 -0
  106. package/dist/devcc/views/WorktreesView.js +235 -0
  107. package/dist/devcc/views/WorktreesView.js.map +1 -0
  108. package/package.json +1 -1
  109. package/src/devcc/agents/agents.ts +282 -0
  110. package/src/devcc/app/App.ts +376 -20
  111. package/src/devcc/app/controlPlaneCommands.ts +197 -0
  112. package/src/devcc/app/keymap.ts +24 -3
  113. package/src/devcc/automation.ts +242 -0
  114. package/src/devcc/cli.ts +45 -3
  115. package/src/devcc/components/Sidebar.ts +1 -0
  116. package/src/devcc/components/primitives.ts +3 -0
  117. package/src/devcc/core/commands.ts +26 -0
  118. package/src/devcc/core/config.ts +271 -11
  119. package/src/devcc/core/state.ts +25 -0
  120. package/src/devcc/environment/EnvironmentService.ts +215 -0
  121. package/src/devcc/environment/integrations.ts +133 -0
  122. package/src/devcc/environment/tools.ts +151 -0
  123. package/src/devcc/git/GitService.ts +104 -2
  124. package/src/devcc/git/worktrees.ts +256 -0
  125. package/src/devcc/index.ts +57 -2
  126. package/src/devcc/platform/launch.ts +125 -0
  127. package/src/devcc/services/ServiceManager.ts +15 -3
  128. package/src/devcc/services/ollama.ts +162 -19
  129. package/src/devcc/services/remote.ts +306 -0
  130. package/src/devcc/services/types.ts +9 -1
  131. package/src/devcc/utils/exec.ts +26 -2
  132. package/src/devcc/verify/verify.ts +316 -0
  133. package/src/devcc/views/OverviewView.ts +76 -1
  134. package/src/devcc/views/ServicesView.ts +17 -0
  135. package/src/devcc/views/SystemView.ts +144 -22
  136. package/src/devcc/views/WorktreesView.ts +270 -0
@@ -0,0 +1,316 @@
1
+ import { spawn } from "node:child_process"
2
+ import { isAbsolute, resolve } from "node:path"
3
+ import type { CommandConfigEntry } from "../core/config.js"
4
+ import { runScriptArgs, type PackageManager } from "../utils/packageManager.js"
5
+
6
+ /**
7
+ * Project verification: the quality gate behind `devcc verify`.
8
+ *
9
+ * Steps are resolved from config (a command id, a package.json script, or an
10
+ * inline command) and run sequentially. The loop is independent of how a step
11
+ * executes, so the CLI can spawn directly while the TUI runs the same steps
12
+ * through the ProcessManager and gets log capture for free.
13
+ */
14
+
15
+ export interface VerifyStepConfig {
16
+ id: string
17
+ name?: string
18
+ command: string
19
+ args?: string[]
20
+ cwd?: string
21
+ }
22
+
23
+ /** A command id or package.json script name, or an inline step. */
24
+ export type VerifyEntry = string | VerifyStepConfig
25
+
26
+ export interface VerifyStep {
27
+ id: string
28
+ name: string
29
+ command: string
30
+ args: string[]
31
+ cwd?: string
32
+ source: "command" | "script" | "inline"
33
+ }
34
+
35
+ export interface ResolveVerifyContext {
36
+ commands: readonly CommandConfigEntry[]
37
+ scripts: readonly { name: string }[]
38
+ packageManager: PackageManager
39
+ }
40
+
41
+ export interface ResolvedVerify {
42
+ steps: VerifyStep[]
43
+ issues: string[]
44
+ }
45
+
46
+ /**
47
+ * Turn config entries into runnable steps. A string names a configured
48
+ * command first, then a package.json script, so `verify: ["lint", "test"]`
49
+ * works without repeating what `package.json` already says.
50
+ */
51
+ export function resolveVerifySteps(
52
+ entries: readonly VerifyEntry[],
53
+ context: ResolveVerifyContext,
54
+ ): ResolvedVerify {
55
+ const steps: VerifyStep[] = []
56
+ const issues: string[] = []
57
+
58
+ for (const entry of entries) {
59
+ if (typeof entry !== "string") {
60
+ steps.push({
61
+ id: entry.id,
62
+ name: entry.name ?? entry.id,
63
+ command: entry.command,
64
+ args: entry.args ?? [],
65
+ cwd: entry.cwd,
66
+ source: "inline",
67
+ })
68
+ continue
69
+ }
70
+
71
+ const command = context.commands.find((candidate) => candidate.id === entry)
72
+ if (command) {
73
+ steps.push({
74
+ id: entry,
75
+ name: command.title,
76
+ command: command.command,
77
+ args: command.args ?? [],
78
+ cwd: command.cwd,
79
+ source: "command",
80
+ })
81
+ continue
82
+ }
83
+
84
+ if (context.scripts.some((script) => script.name === entry)) {
85
+ steps.push({
86
+ id: entry,
87
+ name: entry,
88
+ command: context.packageManager,
89
+ args: runScriptArgs(context.packageManager, entry),
90
+ source: "script",
91
+ })
92
+ continue
93
+ }
94
+
95
+ issues.push(`verify: "${entry}" is neither a command id nor a package.json script`)
96
+ }
97
+
98
+ const seen = new Set<string>()
99
+ for (const step of steps) {
100
+ if (seen.has(step.id)) issues.push(`verify: step "${step.id}" is listed more than once`)
101
+ seen.add(step.id)
102
+ }
103
+
104
+ return { steps, issues }
105
+ }
106
+
107
+ /** Config issues about `verify`, which must block a run rather than silently shrink it. */
108
+ export function isVerifyIssue(issue: string): boolean {
109
+ return issue.startsWith("verify") || issue.startsWith('"verify"')
110
+ }
111
+
112
+ export type VerifyStepStatus = "pending" | "running" | "passed" | "failed" | "skipped"
113
+
114
+ export interface VerifyStepResult {
115
+ id: string
116
+ name: string
117
+ command: string
118
+ args: string[]
119
+ status: VerifyStepStatus
120
+ exitCode: number | null
121
+ durationMs: number
122
+ stdout: string
123
+ stderr: string
124
+ /** Why the step could not run or was stopped (spawn error, signal, cancellation). */
125
+ error?: string
126
+ }
127
+
128
+ export interface VerifyReport {
129
+ ok: boolean
130
+ passed: number
131
+ failed: number
132
+ skipped: number
133
+ total: number
134
+ durationMs: number
135
+ steps: VerifyStepResult[]
136
+ }
137
+
138
+ export interface StepOutcome {
139
+ exitCode: number | null
140
+ stdout?: string
141
+ stderr?: string
142
+ error?: string
143
+ }
144
+
145
+ export type StepExecutor = (step: VerifyStep, signal: AbortSignal) => Promise<StepOutcome>
146
+
147
+ export interface RunVerificationOptions {
148
+ /** Called whenever a step changes state, with the full current list. */
149
+ onProgress?: (steps: readonly VerifyStepResult[]) => void
150
+ signal?: AbortSignal
151
+ }
152
+
153
+ /**
154
+ * Run every step in order. A failing step does not stop the run: the report
155
+ * shows every failure at once. Cancelling marks the remaining steps skipped.
156
+ */
157
+ export async function runVerification(
158
+ steps: readonly VerifyStep[],
159
+ execute: StepExecutor,
160
+ options: RunVerificationOptions = {},
161
+ ): Promise<VerifyReport> {
162
+ const startedAt = Date.now()
163
+ const signal = options.signal ?? new AbortController().signal
164
+ const results: VerifyStepResult[] = steps.map((step) => ({
165
+ id: step.id,
166
+ name: step.name,
167
+ command: step.command,
168
+ args: step.args,
169
+ status: "pending",
170
+ exitCode: null,
171
+ durationMs: 0,
172
+ stdout: "",
173
+ stderr: "",
174
+ }))
175
+ const progress = () => options.onProgress?.(results.map((result) => ({ ...result })))
176
+
177
+ for (const [index, step] of steps.entries()) {
178
+ const result = results[index] as VerifyStepResult
179
+ if (signal.aborted) {
180
+ result.status = "skipped"
181
+ result.error = "cancelled"
182
+ continue
183
+ }
184
+
185
+ result.status = "running"
186
+ progress()
187
+ const stepStart = Date.now()
188
+ let outcome: StepOutcome
189
+ try {
190
+ outcome = await execute(step, signal)
191
+ } catch (error) {
192
+ outcome = { exitCode: null, error: error instanceof Error ? error.message : String(error) }
193
+ }
194
+
195
+ result.durationMs = Date.now() - stepStart
196
+ result.exitCode = outcome.exitCode
197
+ result.stdout = outcome.stdout ?? ""
198
+ result.stderr = outcome.stderr ?? ""
199
+ if (outcome.error) result.error = outcome.error
200
+ result.status = outcome.exitCode === 0 && !outcome.error ? "passed" : "failed"
201
+ progress()
202
+ }
203
+ if (signal.aborted) progress()
204
+
205
+ const passed = results.filter((result) => result.status === "passed").length
206
+ const failed = results.filter((result) => result.status === "failed").length
207
+ const skipped = results.filter((result) => result.status === "skipped").length
208
+ return {
209
+ ok: failed === 0 && skipped === 0 && results.length > 0,
210
+ passed,
211
+ failed,
212
+ skipped,
213
+ total: results.length,
214
+ durationMs: Date.now() - startedAt,
215
+ steps: results,
216
+ }
217
+ }
218
+
219
+ /** One-line summary: `5/5 passed` or `3/5 passed, 2 failed (lint, test)`. */
220
+ export function summariseVerify(
221
+ report: Pick<VerifyReport, "passed" | "failed" | "skipped" | "total" | "steps">,
222
+ ): string {
223
+ const parts = [`${report.passed}/${report.total} passed`]
224
+ if (report.failed > 0) {
225
+ const names = report.steps.filter((step) => step.status === "failed").map((step) => step.name)
226
+ parts.push(`${report.failed} failed (${names.join(", ")})`)
227
+ }
228
+ if (report.skipped > 0) parts.push(`${report.skipped} skipped`)
229
+ return parts.join(", ")
230
+ }
231
+
232
+ // eslint-disable-next-line no-control-regex
233
+ const ANSI_PATTERN = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007]*(?:\u0007|\u001b\\)/gu
234
+
235
+ /** Remove terminal escape sequences, so JSON and logs stay plain text. */
236
+ export function stripAnsi(text: string): string {
237
+ return text.replace(ANSI_PATTERN, "")
238
+ }
239
+
240
+ /** Keep the end of a long output, where test runners and compilers put the verdict. */
241
+ export function keepTail(text: string, maxLength: number): string {
242
+ if (text.length <= maxLength) return text
243
+ return `…[${text.length - maxLength} characters truncated]\n${text.slice(-maxLength)}`
244
+ }
245
+
246
+ export interface SpawnExecutorOptions {
247
+ rootDir: string
248
+ env?: NodeJS.ProcessEnv
249
+ /** Characters of stdout/stderr kept per step (default 200k, from the end). */
250
+ maxOutput?: number
251
+ /** Live output, e.g. to mirror a failing step. */
252
+ onOutput?: (step: VerifyStep, chunk: string, stream: "stdout" | "stderr") => void
253
+ }
254
+
255
+ /**
256
+ * Executor for the CLI: `spawn(command, args)` in the project, colour disabled,
257
+ * output captured (tail kept) and ANSI-stripped. No shell, no timeout - a test
258
+ * suite takes as long as it takes - but an abort signal stops the child.
259
+ */
260
+ export function createSpawnExecutor(options: SpawnExecutorOptions): StepExecutor {
261
+ const maxOutput = options.maxOutput ?? 200_000
262
+ return (step, signal) =>
263
+ new Promise<StepOutcome>((resolvePromise) => {
264
+ const cwd = step.cwd
265
+ ? isAbsolute(step.cwd)
266
+ ? step.cwd
267
+ : resolve(options.rootDir, step.cwd)
268
+ : options.rootDir
269
+ let stdout = ""
270
+ let stderr = ""
271
+ const append = (current: string, chunk: string) => {
272
+ const next = current + chunk
273
+ return next.length > maxOutput * 2 ? next.slice(-maxOutput) : next
274
+ }
275
+
276
+ const child = spawn(step.command, step.args, {
277
+ cwd,
278
+ env: { ...(options.env ?? process.env), FORCE_COLOR: "0", NO_COLOR: "1" },
279
+ stdio: ["ignore", "pipe", "pipe"],
280
+ windowsHide: true,
281
+ })
282
+
283
+ const onAbort = () => child.kill("SIGTERM")
284
+ signal.addEventListener("abort", onAbort, { once: true })
285
+
286
+ child.stdout.setEncoding("utf8")
287
+ child.stdout.on("data", (chunk: string) => {
288
+ stdout = append(stdout, chunk)
289
+ options.onOutput?.(step, chunk, "stdout")
290
+ })
291
+ child.stderr.setEncoding("utf8")
292
+ child.stderr.on("data", (chunk: string) => {
293
+ stderr = append(stderr, chunk)
294
+ options.onOutput?.(step, chunk, "stderr")
295
+ })
296
+
297
+ let settled = false
298
+ const finish = (outcome: StepOutcome) => {
299
+ if (settled) return
300
+ settled = true
301
+ signal.removeEventListener("abort", onAbort)
302
+ resolvePromise({
303
+ ...outcome,
304
+ stdout: keepTail(stripAnsi(stdout), maxOutput),
305
+ stderr: keepTail(stripAnsi(stderr), maxOutput),
306
+ })
307
+ }
308
+
309
+ child.once("error", (error) => finish({ exitCode: null, error: error.message }))
310
+ child.once("close", (code, closeSignal) => {
311
+ if (signal.aborted) finish({ exitCode: code, error: "cancelled" })
312
+ else if (closeSignal) finish({ exitCode: code, error: `terminated by ${closeSignal}` })
313
+ else finish({ exitCode: code })
314
+ })
315
+ })
316
+ }
@@ -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,6 +52,43 @@ 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 -------------------------------------------
@@ -135,6 +173,16 @@ export class OverviewView extends View {
135
173
  rows.push(info("node", `${state.project.host.runtime}`, labelWidth))
136
174
  }
137
175
  }
176
+ const attention = environmentAttention(state)
177
+ if (attention) {
178
+ rows.push(
179
+ infoStyled(
180
+ "environment",
181
+ styled(chunk(attention, theme.warning), dim(" (8 for details)")),
182
+ labelWidth,
183
+ ),
184
+ )
185
+ }
138
186
 
139
187
  this.list.setRows(rows, -1)
140
188
  }
@@ -144,7 +192,8 @@ export class OverviewView extends View {
144
192
  { keys: "↑↓", label: "navigate" },
145
193
  { keys: "p", label: "processes" },
146
194
  { keys: "g", label: "git" },
147
- { keys: "l", label: "logs" },
195
+ { keys: "w", label: "worktrees" },
196
+ { keys: "V", label: "verify" },
148
197
  { keys: "/", label: "commands" },
149
198
  { keys: "?", label: "help" },
150
199
  { keys: "q", label: "quit" },
@@ -180,6 +229,32 @@ function infoStyled(label: string, value: ReturnType<typeof styled>, labelWidth
180
229
  }
181
230
  }
182
231
 
232
+ function verifyRow(state: AppState, labelWidth: number): ListRow {
233
+ const { verify } = state
234
+ if (verify.steps.length === 0) {
235
+ return info("status", `${verify.configured} step(s) configured - press V to run`, labelWidth)
236
+ }
237
+ const passed = verify.steps.filter((step) => step.status === "passed").length
238
+ const failed = verify.steps.filter((step) => step.status === "failed")
239
+ const running = verify.steps.find((step) => step.status === "running")
240
+ const color = verify.running ? theme.warning : verify.ok ? theme.success : theme.danger
241
+ const summary = verify.running
242
+ ? `${passed}/${verify.steps.length} passed · running ${running?.name ?? "…"}`
243
+ : `${passed}/${verify.steps.length} passed${failed.length > 0 ? ` · failed: ${failed.map((step) => step.name).join(", ")}` : ""}`
244
+ return infoStyled("status", styled(chunk(summary, color)), labelWidth)
245
+ }
246
+
247
+ /** One line when the environment needs a look, otherwise nothing. */
248
+ function environmentAttention(state: AppState): string | null {
249
+ const { tools, direnv, mise } = state.environment
250
+ const problems = tools
251
+ .filter((tool) => tool.status === "error" || tool.status === "warning")
252
+ .map((tool) => tool.name)
253
+ if (mise.error) problems.push("mise")
254
+ if (direnv.installed && direnv.rcPath && direnv.approval !== "allowed") problems.push(".envrc not allowed")
255
+ return problems.length > 0 ? problems.join(", ") : null
256
+ }
257
+
183
258
  function usageColor(ratio: number): string {
184
259
  if (ratio > 0.85) return theme.danger
185
260
  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 {