@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.
- package/README.md +589 -389
- package/bin/devcc.mjs +50 -23
- package/dist/devcc/agents/agents.d.ts +119 -0
- package/dist/devcc/agents/agents.d.ts.map +1 -0
- package/dist/devcc/agents/agents.js +182 -0
- package/dist/devcc/agents/agents.js.map +1 -0
- package/dist/devcc/app/App.d.ts +23 -0
- package/dist/devcc/app/App.d.ts.map +1 -1
- package/dist/devcc/app/App.js +451 -19
- package/dist/devcc/app/App.js.map +1 -1
- package/dist/devcc/app/controlPlaneCommands.d.ts +14 -0
- package/dist/devcc/app/controlPlaneCommands.d.ts.map +1 -0
- package/dist/devcc/app/controlPlaneCommands.js +204 -0
- package/dist/devcc/app/controlPlaneCommands.js.map +1 -0
- package/dist/devcc/app/keymap.d.ts.map +1 -1
- package/dist/devcc/app/keymap.js +25 -3
- package/dist/devcc/app/keymap.js.map +1 -1
- package/dist/devcc/automation.d.ts +52 -0
- package/dist/devcc/automation.d.ts.map +1 -0
- package/dist/devcc/automation.js +339 -0
- package/dist/devcc/automation.js.map +1 -0
- package/dist/devcc/cli.d.ts.map +1 -1
- package/dist/devcc/cli.js +73 -3
- package/dist/devcc/cli.js.map +1 -1
- package/dist/devcc/components/Sidebar.d.ts.map +1 -1
- package/dist/devcc/components/Sidebar.js +1 -0
- package/dist/devcc/components/Sidebar.js.map +1 -1
- package/dist/devcc/components/primitives.d.ts.map +1 -1
- package/dist/devcc/components/primitives.js +3 -0
- package/dist/devcc/components/primitives.js.map +1 -1
- package/dist/devcc/core/commands.d.ts +33 -0
- package/dist/devcc/core/commands.d.ts.map +1 -1
- package/dist/devcc/core/commands.js.map +1 -1
- package/dist/devcc/core/config.d.ts +71 -3
- package/dist/devcc/core/config.d.ts.map +1 -1
- package/dist/devcc/core/config.js +189 -5
- package/dist/devcc/core/config.js.map +1 -1
- package/dist/devcc/core/state.d.ts +19 -1
- package/dist/devcc/core/state.d.ts.map +1 -1
- package/dist/devcc/core/state.js +6 -0
- package/dist/devcc/core/state.js.map +1 -1
- package/dist/devcc/environment/EnvironmentService.d.ts +58 -0
- package/dist/devcc/environment/EnvironmentService.d.ts.map +1 -0
- package/dist/devcc/environment/EnvironmentService.js +159 -0
- package/dist/devcc/environment/EnvironmentService.js.map +1 -0
- package/dist/devcc/environment/integrations.d.ts +52 -0
- package/dist/devcc/environment/integrations.d.ts.map +1 -0
- package/dist/devcc/environment/integrations.js +94 -0
- package/dist/devcc/environment/integrations.js.map +1 -0
- package/dist/devcc/environment/tools.d.ts +51 -0
- package/dist/devcc/environment/tools.d.ts.map +1 -0
- package/dist/devcc/environment/tools.js +99 -0
- package/dist/devcc/environment/tools.js.map +1 -0
- package/dist/devcc/git/GitService.d.ts +26 -0
- package/dist/devcc/git/GitService.d.ts.map +1 -1
- package/dist/devcc/git/GitService.js +71 -2
- package/dist/devcc/git/GitService.js.map +1 -1
- package/dist/devcc/git/worktrees.d.ts +88 -0
- package/dist/devcc/git/worktrees.d.ts.map +1 -0
- package/dist/devcc/git/worktrees.js +226 -0
- package/dist/devcc/git/worktrees.js.map +1 -0
- package/dist/devcc/index.d.ts +15 -3
- package/dist/devcc/index.d.ts.map +1 -1
- package/dist/devcc/index.js +14 -2
- package/dist/devcc/index.js.map +1 -1
- package/dist/devcc/init/detect.d.ts +55 -0
- package/dist/devcc/init/detect.d.ts.map +1 -0
- package/dist/devcc/init/detect.js +207 -0
- package/dist/devcc/init/detect.js.map +1 -0
- package/dist/devcc/init/prompt.d.ts +25 -0
- package/dist/devcc/init/prompt.d.ts.map +1 -0
- package/dist/devcc/init/prompt.js +156 -0
- package/dist/devcc/init/prompt.js.map +1 -0
- package/dist/devcc/platform/launch.d.ts +30 -0
- package/dist/devcc/platform/launch.d.ts.map +1 -0
- package/dist/devcc/platform/launch.js +93 -0
- package/dist/devcc/platform/launch.js.map +1 -0
- package/dist/devcc/services/ServiceManager.d.ts +10 -2
- package/dist/devcc/services/ServiceManager.d.ts.map +1 -1
- package/dist/devcc/services/ServiceManager.js +18 -4
- package/dist/devcc/services/ServiceManager.js.map +1 -1
- package/dist/devcc/services/node.d.ts +11 -0
- package/dist/devcc/services/node.d.ts.map +1 -1
- package/dist/devcc/services/node.js +20 -18
- package/dist/devcc/services/node.js.map +1 -1
- package/dist/devcc/services/ollama.d.ts +64 -5
- package/dist/devcc/services/ollama.d.ts.map +1 -1
- package/dist/devcc/services/ollama.js +118 -17
- package/dist/devcc/services/ollama.js.map +1 -1
- package/dist/devcc/services/remote.d.ts +90 -0
- package/dist/devcc/services/remote.d.ts.map +1 -0
- package/dist/devcc/services/remote.js +233 -0
- package/dist/devcc/services/remote.js.map +1 -0
- package/dist/devcc/services/types.d.ts +8 -1
- package/dist/devcc/services/types.d.ts.map +1 -1
- package/dist/devcc/services/types.js.map +1 -1
- package/dist/devcc/utils/exec.d.ts +6 -0
- package/dist/devcc/utils/exec.d.ts.map +1 -1
- package/dist/devcc/utils/exec.js +12 -2
- package/dist/devcc/utils/exec.js.map +1 -1
- package/dist/devcc/verify/verify.d.ts +107 -0
- package/dist/devcc/verify/verify.d.ts.map +1 -0
- package/dist/devcc/verify/verify.js +203 -0
- package/dist/devcc/verify/verify.js.map +1 -0
- package/dist/devcc/views/OverviewView.d.ts.map +1 -1
- package/dist/devcc/views/OverviewView.js +58 -2
- package/dist/devcc/views/OverviewView.js.map +1 -1
- package/dist/devcc/views/ServicesView.d.ts +2 -0
- package/dist/devcc/views/ServicesView.d.ts.map +1 -1
- package/dist/devcc/views/ServicesView.js +15 -0
- package/dist/devcc/views/ServicesView.js.map +1 -1
- package/dist/devcc/views/SystemView.d.ts +10 -1
- package/dist/devcc/views/SystemView.d.ts.map +1 -1
- package/dist/devcc/views/SystemView.js +127 -19
- package/dist/devcc/views/SystemView.js.map +1 -1
- package/dist/devcc/views/WorktreesView.d.ts +48 -0
- package/dist/devcc/views/WorktreesView.d.ts.map +1 -0
- package/dist/devcc/views/WorktreesView.js +235 -0
- package/dist/devcc/views/WorktreesView.js.map +1 -0
- package/package.json +1 -1
- package/src/devcc/agents/agents.ts +292 -0
- package/src/devcc/app/App.ts +529 -22
- package/src/devcc/app/controlPlaneCommands.ts +227 -0
- package/src/devcc/app/keymap.ts +25 -3
- package/src/devcc/automation.ts +409 -0
- package/src/devcc/cli.ts +77 -3
- package/src/devcc/components/Sidebar.ts +1 -0
- package/src/devcc/components/primitives.ts +3 -0
- package/src/devcc/core/commands.ts +34 -0
- package/src/devcc/core/config.ts +275 -11
- package/src/devcc/core/state.ts +25 -0
- package/src/devcc/environment/EnvironmentService.ts +215 -0
- package/src/devcc/environment/integrations.ts +133 -0
- package/src/devcc/environment/tools.ts +151 -0
- package/src/devcc/git/GitService.ts +104 -2
- package/src/devcc/git/worktrees.ts +256 -0
- package/src/devcc/index.ts +66 -2
- package/src/devcc/init/detect.ts +268 -0
- package/src/devcc/init/prompt.ts +179 -0
- package/src/devcc/platform/launch.ts +125 -0
- package/src/devcc/services/ServiceManager.ts +15 -3
- package/src/devcc/services/node.ts +17 -14
- package/src/devcc/services/ollama.ts +162 -19
- package/src/devcc/services/remote.ts +306 -0
- package/src/devcc/services/types.ts +9 -1
- package/src/devcc/utils/exec.ts +26 -2
- package/src/devcc/verify/verify.ts +316 -0
- package/src/devcc/views/OverviewView.ts +84 -2
- package/src/devcc/views/ServicesView.ts +17 -0
- package/src/devcc/views/SystemView.ts +144 -22
- 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(
|
|
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: "
|
|
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
|
-
/**
|
|
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("
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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-${
|
|
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
|
+
}
|