@aion0/forge 0.5.43 β 0.5.44
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/RELEASE_NOTES.md +14 -5
- package/components/Dashboard.tsx +14 -0
- package/intellij-plugin/README.md +53 -0
- package/intellij-plugin/build.gradle.kts +60 -0
- package/intellij-plugin/gradle/gradle-daemon-jvm.properties +12 -0
- package/intellij-plugin/gradle.properties +9 -0
- package/intellij-plugin/publish.sh +78 -0
- package/intellij-plugin/settings.gradle.kts +7 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/action/LoginAction.kt +49 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/action/LogoutAction.kt +18 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/action/OpenWebUIAction.kt +13 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/action/SwitchConnectionAction.kt +26 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/api/ForgeClient.kt +115 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/auth/Auth.kt +31 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/connection/ConnectionManager.kt +95 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/settings/ForgeConfigurable.kt +81 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/DocsView.kt +99 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/ForgeStatusBarWidgetFactory.kt +94 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/ForgeToolWindowFactory.kt +27 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/ForgeTreeView.kt +176 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/Helpers.kt +48 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/PipelinesView.kt +226 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/TerminalsView.kt +309 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/TreeNodeData.kt +33 -0
- package/intellij-plugin/src/main/kotlin/com/aion0/forge/ui/toolwindow/WorkspacesView.kt +166 -0
- package/intellij-plugin/src/main/resources/META-INF/plugin.xml +88 -0
- package/intellij-plugin/src/main/resources/icons/forge.svg +3 -0
- package/lib/agents/index.ts +1 -1
- package/lib/help-docs/00-overview.md +1 -0
- package/lib/help-docs/13-ide-plugins.md +90 -0
- package/lib/help-docs/CLAUDE.md +3 -0
- package/package.json +1 -1
- package/vscode-extension/.vscodeignore +11 -0
- package/vscode-extension/README.md +48 -0
- package/vscode-extension/media/icon.png +0 -0
- package/vscode-extension/media/icon.svg +3 -0
- package/vscode-extension/package-lock.json +4046 -0
- package/vscode-extension/package.json +514 -0
- package/vscode-extension/publish.sh +49 -0
- package/vscode-extension/src/api/client.ts +217 -0
- package/vscode-extension/src/auth/auth.ts +32 -0
- package/vscode-extension/src/commands/auth.ts +44 -0
- package/vscode-extension/src/commands/connection.ts +113 -0
- package/vscode-extension/src/commands/docs.ts +40 -0
- package/vscode-extension/src/commands/pipeline.ts +103 -0
- package/vscode-extension/src/commands/server.ts +50 -0
- package/vscode-extension/src/commands/smith.ts +112 -0
- package/vscode-extension/src/commands/task.ts +43 -0
- package/vscode-extension/src/commands/terminal.ts +279 -0
- package/vscode-extension/src/commands/workspace.ts +138 -0
- package/vscode-extension/src/connection/manager.ts +80 -0
- package/vscode-extension/src/docs/fs-provider.ts +94 -0
- package/vscode-extension/src/docs/result-provider.ts +33 -0
- package/vscode-extension/src/docs/transport.ts +22 -0
- package/vscode-extension/src/extension.ts +314 -0
- package/vscode-extension/src/statusbar.ts +70 -0
- package/vscode-extension/src/terminal/pseudoterm.ts +123 -0
- package/vscode-extension/src/views/docs.ts +145 -0
- package/vscode-extension/src/views/pipelines.ts +222 -0
- package/vscode-extension/src/views/terminals.ts +91 -0
- package/vscode-extension/src/views/workspaces.ts +243 -0
- package/vscode-extension/tsconfig.json +16 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
package com.aion0.forge.ui.toolwindow
|
|
2
|
+
|
|
3
|
+
import com.aion0.forge.api.ForgeClient
|
|
4
|
+
import com.intellij.icons.AllIcons
|
|
5
|
+
import com.intellij.openapi.actionSystem.AnAction
|
|
6
|
+
import com.intellij.openapi.actionSystem.AnActionEvent
|
|
7
|
+
import com.intellij.openapi.fileEditor.FileEditorManager
|
|
8
|
+
import com.intellij.openapi.project.Project
|
|
9
|
+
import com.intellij.openapi.ui.Messages
|
|
10
|
+
import com.intellij.testFramework.LightVirtualFile
|
|
11
|
+
import java.net.URLEncoder
|
|
12
|
+
import javax.swing.tree.DefaultMutableTreeNode
|
|
13
|
+
|
|
14
|
+
class PipelinesView(project: Project) : ForgeTreeView(project) {
|
|
15
|
+
|
|
16
|
+
override fun rootLabel() = "pipelines"
|
|
17
|
+
|
|
18
|
+
override fun reload(): List<DefaultMutableTreeNode> {
|
|
19
|
+
val r = ForgeClient.get().request("/api/projects")
|
|
20
|
+
if (r.status == 401 || r.status == 403) return listOf(DefaultMutableTreeNode(TreeNodeData.Hint("π Tools β Forge: Login")))
|
|
21
|
+
if (!r.ok || r.data == null || !r.data.isJsonArray) {
|
|
22
|
+
return listOf(DefaultMutableTreeNode(TreeNodeData.Hint("β ${r.error ?: "Not connected"}")))
|
|
23
|
+
}
|
|
24
|
+
val arr = r.data.asJsonArray
|
|
25
|
+
if (arr.size() == 0) return listOf(DefaultMutableTreeNode(TreeNodeData.Hint("No projects")))
|
|
26
|
+
|
|
27
|
+
return arr.toList().sortedBy { it.asJsonObject.get("name")?.asString ?: "" }.mapNotNull { el ->
|
|
28
|
+
val p = el.asJsonObject
|
|
29
|
+
val name = p.get("name")?.asString ?: return@mapNotNull null
|
|
30
|
+
val path = p.get("path")?.asString ?: return@mapNotNull null
|
|
31
|
+
val node = DefaultMutableTreeNode(TreeNodeData.PipelineProject("π $name", path, name))
|
|
32
|
+
|
|
33
|
+
val pp = ForgeClient.get().request("/api/project-pipelines?project=${URLEncoder.encode(path, "UTF-8")}")
|
|
34
|
+
if (pp.ok && pp.data?.isJsonObject == true) {
|
|
35
|
+
val obj = pp.data.asJsonObject
|
|
36
|
+
val bindings = obj.getAsJsonArray("bindings")
|
|
37
|
+
val runs = obj.getAsJsonArray("runs")
|
|
38
|
+
if (bindings == null || bindings.size() == 0) {
|
|
39
|
+
node.add(DefaultMutableTreeNode(TreeNodeData.Hint("οΌ No pipelines yet β right-click to add")))
|
|
40
|
+
} else {
|
|
41
|
+
for (bEl in bindings) {
|
|
42
|
+
val b = bEl.asJsonObject
|
|
43
|
+
val wf = b.get("workflowName")?.asString ?: "?"
|
|
44
|
+
val enabled = b.get("enabled")?.asBoolean ?: true
|
|
45
|
+
val schedule = b.getAsJsonObject("config")?.get("schedule")?.asString
|
|
46
|
+
?: b.getAsJsonObject("config")?.get("cron")?.asString
|
|
47
|
+
?: "manual"
|
|
48
|
+
val mark = if (enabled) "β" else "β"
|
|
49
|
+
node.add(DefaultMutableTreeNode(
|
|
50
|
+
TreeNodeData.PipelineBinding("$mark $wf ($schedule)", path, name, wf, enabled),
|
|
51
|
+
))
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (runs != null && runs.size() > 0) {
|
|
55
|
+
val runsHead = DefaultMutableTreeNode(TreeNodeData.Hint("π Recent Runs"))
|
|
56
|
+
runs.take(10).forEach { rEl ->
|
|
57
|
+
val run = rEl.asJsonObject
|
|
58
|
+
val status = run.get("status")?.asString ?: "?"
|
|
59
|
+
val wf = run.get("workflowName")?.asString ?: "?"
|
|
60
|
+
val pipelineId = run.get("pipelineId")?.asString ?: run.get("id")?.asString ?: ""
|
|
61
|
+
val createdAt = run.get("createdAt")?.asString ?: ""
|
|
62
|
+
val emoji = when (status) {
|
|
63
|
+
"running" -> "βΆ"; "done" -> "β"; "failed" -> "β"; "cancelled" -> "β"; else -> "Β·"
|
|
64
|
+
}
|
|
65
|
+
runsHead.add(DefaultMutableTreeNode(
|
|
66
|
+
TreeNodeData.PipelineRun("$emoji $wf Β· $createdAt", pipelineId, wf, status),
|
|
67
|
+
))
|
|
68
|
+
}
|
|
69
|
+
node.add(runsHead)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
node
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
override fun onDoubleClick(data: TreeNodeData, node: DefaultMutableTreeNode) {
|
|
77
|
+
when (data) {
|
|
78
|
+
is TreeNodeData.PipelineBinding -> triggerBinding(data)
|
|
79
|
+
is TreeNodeData.PipelineRun -> expandRun(node, data)
|
|
80
|
+
is TreeNodeData.PipelineNode -> showNodeResult(data)
|
|
81
|
+
else -> {}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
override fun contextActions(data: TreeNodeData, node: DefaultMutableTreeNode): List<AnAction> = when (data) {
|
|
86
|
+
is TreeNodeData.PipelineProject -> listOf(
|
|
87
|
+
act("Add Pipelineβ¦", AllIcons.General.Add) { addPipeline(data) },
|
|
88
|
+
)
|
|
89
|
+
is TreeNodeData.PipelineBinding -> listOf(
|
|
90
|
+
act("Trigger Now", AllIcons.Actions.Execute) { triggerBinding(data) },
|
|
91
|
+
act(if (data.enabled) "Disable" else "Enable", AllIcons.Actions.ToggleSoftWrap) { toggleBinding(data) },
|
|
92
|
+
act("Removeβ¦", AllIcons.Actions.Cancel) { removeBinding(data) },
|
|
93
|
+
)
|
|
94
|
+
is TreeNodeData.PipelineRun -> listOf(
|
|
95
|
+
act("Show Nodes", AllIcons.Actions.Expandall) { expandRun(node, data) },
|
|
96
|
+
)
|
|
97
|
+
is TreeNodeData.PipelineNode -> listOf(
|
|
98
|
+
act("Show Result", AllIcons.Actions.Preview) { showNodeResult(data) },
|
|
99
|
+
)
|
|
100
|
+
else -> emptyList()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private fun triggerBinding(b: TreeNodeData.PipelineBinding) {
|
|
104
|
+
runApi(project, "Trigger ${b.workflowName}", {
|
|
105
|
+
ForgeClient.get().request(
|
|
106
|
+
"/api/project-pipelines",
|
|
107
|
+
method = "POST",
|
|
108
|
+
body = mapOf("action" to "trigger", "projectPath" to b.projectPath, "projectName" to b.projectName, "workflowName" to b.workflowName, "input" to emptyMap<String, String>()),
|
|
109
|
+
)
|
|
110
|
+
}) { refresh() }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private fun toggleBinding(b: TreeNodeData.PipelineBinding) {
|
|
114
|
+
val next = !b.enabled
|
|
115
|
+
runApi(project, if (next) "Enable ${b.workflowName}" else "Disable ${b.workflowName}", {
|
|
116
|
+
ForgeClient.get().request(
|
|
117
|
+
"/api/project-pipelines",
|
|
118
|
+
method = "POST",
|
|
119
|
+
body = mapOf("action" to "update", "projectPath" to b.projectPath, "workflowName" to b.workflowName, "enabled" to next),
|
|
120
|
+
)
|
|
121
|
+
}) { refresh() }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private fun removeBinding(b: TreeNodeData.PipelineBinding) {
|
|
125
|
+
val r = Messages.showYesNoDialog(project, "Remove pipeline \"${b.workflowName}\"?", "Forge", Messages.getQuestionIcon())
|
|
126
|
+
if (r != Messages.YES) return
|
|
127
|
+
runApi(project, "Remove ${b.workflowName}", {
|
|
128
|
+
ForgeClient.get().request(
|
|
129
|
+
"/api/project-pipelines",
|
|
130
|
+
method = "POST",
|
|
131
|
+
body = mapOf("action" to "remove", "projectPath" to b.projectPath, "workflowName" to b.workflowName),
|
|
132
|
+
)
|
|
133
|
+
}) { refresh() }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private fun addPipeline(p: TreeNodeData.PipelineProject) {
|
|
137
|
+
val pp = ForgeClient.get().request("/api/project-pipelines?project=${URLEncoder.encode(p.projectPath, "UTF-8")}")
|
|
138
|
+
val workflows = pp.data?.asJsonObject?.getAsJsonArray("workflows")?.map { it.asJsonObject.get("name").asString } ?: return
|
|
139
|
+
val bound = pp.data.asJsonObject.getAsJsonArray("bindings")?.map { it.asJsonObject.get("workflowName").asString }?.toSet() ?: emptySet()
|
|
140
|
+
val candidates = workflows.filter { it !in bound }
|
|
141
|
+
if (candidates.isEmpty()) { notify(project, "Forge: all workflows are already bound to ${p.projectName}"); return }
|
|
142
|
+
val choice = Messages.showEditableChooseDialog("Workflow to bind", "Add Pipeline", null, candidates.toTypedArray(), candidates.first(), null) ?: return
|
|
143
|
+
runApi(project, "Add $choice to ${p.projectName}", {
|
|
144
|
+
ForgeClient.get().request(
|
|
145
|
+
"/api/project-pipelines",
|
|
146
|
+
method = "POST",
|
|
147
|
+
body = mapOf("action" to "add", "projectPath" to p.projectPath, "projectName" to p.projectName, "workflowName" to choice, "config" to emptyMap<String, Any>()),
|
|
148
|
+
)
|
|
149
|
+
}) { refresh() }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private fun expandRun(parent: DefaultMutableTreeNode, run: TreeNodeData.PipelineRun) {
|
|
153
|
+
if (run.pipelineId.isBlank()) { notify(project, "Forge: this run has no pipeline detail id."); return }
|
|
154
|
+
val r = ForgeClient.get().request("/api/pipelines/${run.pipelineId}")
|
|
155
|
+
if (!r.ok || r.data == null || !r.data.isJsonObject) {
|
|
156
|
+
notify(project, "Forge: ${r.error ?: "failed to load run"}", com.intellij.notification.NotificationType.WARNING)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
val nodes = r.data.asJsonObject.getAsJsonObject("nodes")
|
|
160
|
+
val order = r.data.asJsonObject.getAsJsonArray("nodeOrder")
|
|
161
|
+
?.map { it.asString } ?: nodes?.keySet()?.toList() ?: emptyList()
|
|
162
|
+
com.intellij.openapi.application.ApplicationManager.getApplication().invokeLater {
|
|
163
|
+
parent.removeAllChildren()
|
|
164
|
+
for (name in order) {
|
|
165
|
+
val n = nodes?.getAsJsonObject(name) ?: continue
|
|
166
|
+
val st = n.get("status")?.asString ?: "pending"
|
|
167
|
+
val err = n.get("error")?.asString
|
|
168
|
+
val taskId = n.get("taskId")?.asString
|
|
169
|
+
val emoji = when (st) {
|
|
170
|
+
"running" -> "βΆ"; "done" -> "β"; "failed" -> "β"
|
|
171
|
+
"cancelled" -> "β"; "skipped" -> "Β·"; else -> "Β·"
|
|
172
|
+
}
|
|
173
|
+
parent.add(DefaultMutableTreeNode(
|
|
174
|
+
TreeNodeData.PipelineNode("$emoji $name ($st)", run.pipelineId, name, st, err, taskId),
|
|
175
|
+
))
|
|
176
|
+
}
|
|
177
|
+
treeModel.reload(parent)
|
|
178
|
+
tree.expandPath(javax.swing.tree.TreePath(parent.path))
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Open a markdown buffer with status + error + (if available) the linked
|
|
183
|
+
* task's prompt / result / log / git diff. */
|
|
184
|
+
private fun showNodeResult(n: TreeNodeData.PipelineNode) {
|
|
185
|
+
runBg(project, "Loading node result") {
|
|
186
|
+
val sb = StringBuilder()
|
|
187
|
+
sb.append("# Pipeline node: `${n.nodeName}`\n\n")
|
|
188
|
+
sb.append("**Status:** ${n.status}\n")
|
|
189
|
+
if (n.taskId != null) sb.append("**Task ID:** `${n.taskId}`\n")
|
|
190
|
+
sb.append("\n")
|
|
191
|
+
|
|
192
|
+
if (n.taskId != null) {
|
|
193
|
+
val t = ForgeClient.get().request("/api/tasks/${n.taskId}")
|
|
194
|
+
if (t.ok && t.data?.isJsonObject == true) {
|
|
195
|
+
val task = t.data.asJsonObject
|
|
196
|
+
task.get("prompt")?.asString?.let { sb.append("## Prompt\n```\n$it\n```\n\n") }
|
|
197
|
+
task.get("resultSummary")?.asString?.let { sb.append("## Result\n$it\n\n") }
|
|
198
|
+
task.get("gitDiff")?.asString?.let { sb.append("## Git Diff\n```diff\n$it\n```\n\n") }
|
|
199
|
+
val log = task.getAsJsonArray("log")
|
|
200
|
+
if (log != null && log.size() > 0) {
|
|
201
|
+
sb.append("## Log (last 20)\n")
|
|
202
|
+
log.toList().takeLast(20).forEach { e ->
|
|
203
|
+
val o = e.asJsonObject
|
|
204
|
+
val tag = o.get("subtype")?.asString ?: o.get("type")?.asString ?: "log"
|
|
205
|
+
val content = o.get("content")?.asString ?: ""
|
|
206
|
+
sb.append("- `[$tag]` ${content.take(500).replace("\n", " ")}\n")
|
|
207
|
+
}
|
|
208
|
+
sb.append("\n")
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (!n.error.isNullOrBlank()) sb.append("## Error\n```\n${n.error}\n```\n")
|
|
213
|
+
|
|
214
|
+
val content = sb.toString()
|
|
215
|
+
com.intellij.openapi.application.ApplicationManager.getApplication().invokeLater {
|
|
216
|
+
val vf = LightVirtualFile("forge-node-${n.nodeName}.md", content)
|
|
217
|
+
vf.isWritable = false
|
|
218
|
+
FileEditorManager.getInstance(project).openFile(vf, true)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private fun act(name: String, icon: javax.swing.Icon?, run: () -> Unit) = object : AnAction(name, null, icon) {
|
|
224
|
+
override fun actionPerformed(e: AnActionEvent) = run()
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
package com.aion0.forge.ui.toolwindow
|
|
2
|
+
|
|
3
|
+
import com.aion0.forge.api.ForgeClient
|
|
4
|
+
import com.intellij.icons.AllIcons
|
|
5
|
+
import com.intellij.openapi.actionSystem.AnAction
|
|
6
|
+
import com.intellij.openapi.actionSystem.AnActionEvent
|
|
7
|
+
import com.intellij.openapi.actionSystem.DefaultActionGroup
|
|
8
|
+
import com.intellij.openapi.application.ApplicationManager
|
|
9
|
+
import com.intellij.openapi.project.Project
|
|
10
|
+
import com.intellij.openapi.ui.Messages
|
|
11
|
+
import org.jetbrains.plugins.terminal.LocalTerminalDirectRunner
|
|
12
|
+
import org.jetbrains.plugins.terminal.ShellStartupOptions
|
|
13
|
+
import org.jetbrains.plugins.terminal.TerminalTabState
|
|
14
|
+
import org.jetbrains.plugins.terminal.TerminalToolWindowManager
|
|
15
|
+
import java.net.URLEncoder
|
|
16
|
+
import javax.swing.tree.DefaultMutableTreeNode
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Project-keyed terminal launcher.
|
|
20
|
+
*
|
|
21
|
+
* Each top-level node is a forge project. Expand it to see its claude sessions
|
|
22
|
+
* (most-recent first, bound session marked β
). Double-click a session resumes
|
|
23
|
+
* exactly that session via `claude --resume <id>`. Right-click β Open With
|
|
24
|
+
* lists every configured agent for a fresh launch.
|
|
25
|
+
*
|
|
26
|
+
* Agents are spawned **directly as the pty process** via a custom
|
|
27
|
+
* [LocalTerminalDirectRunner]: no shell, no `executeCommand`, so there is no
|
|
28
|
+
* race with prompt-detection or shell startup that could swallow keystrokes.
|
|
29
|
+
*/
|
|
30
|
+
class TerminalsView(project: Project) : ForgeTreeView(project) {
|
|
31
|
+
|
|
32
|
+
/** Cached list of enabled agents β refreshed on every reload so the project
|
|
33
|
+
* right-click submenu can list them synchronously. */
|
|
34
|
+
@Volatile private var agentsCache: List<Triple<String, String, String?>> = emptyList()
|
|
35
|
+
|
|
36
|
+
override fun rootLabel() = "terminals"
|
|
37
|
+
|
|
38
|
+
override fun reload(): List<DefaultMutableTreeNode> {
|
|
39
|
+
// Refresh agent cache for the right-click submenu (cheap; ignore failures).
|
|
40
|
+
ForgeClient.get().request("/api/agents").let { ar ->
|
|
41
|
+
if (ar.ok && ar.data?.isJsonObject == true) {
|
|
42
|
+
agentsCache = ar.data.asJsonObject.getAsJsonArray("agents")?.toList()?.mapNotNull { el ->
|
|
43
|
+
val o = el.asJsonObject
|
|
44
|
+
if (o.get("enabled")?.asBoolean == false) return@mapNotNull null
|
|
45
|
+
val id = o.get("id")?.asString ?: return@mapNotNull null
|
|
46
|
+
val name = o.get("name")?.asString ?: id
|
|
47
|
+
Triple(id, name, o.get("cliType")?.asString)
|
|
48
|
+
}.orEmpty()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
val r = ForgeClient.get().request("/api/projects")
|
|
53
|
+
if (r.status == 401 || r.status == 403) return listOf(DefaultMutableTreeNode(TreeNodeData.Hint("π Tools β Forge: Login")))
|
|
54
|
+
if (!r.ok || r.data == null || !r.data.isJsonArray) {
|
|
55
|
+
return listOf(DefaultMutableTreeNode(TreeNodeData.Hint("β ${r.error ?: "Not connected"}")))
|
|
56
|
+
}
|
|
57
|
+
val arr = r.data.asJsonArray
|
|
58
|
+
if (arr.size() == 0) return listOf(DefaultMutableTreeNode(TreeNodeData.Hint("No projects yet")))
|
|
59
|
+
|
|
60
|
+
return arr.toList()
|
|
61
|
+
.sortedBy { it.asJsonObject.get("name")?.asString ?: "" }
|
|
62
|
+
.mapNotNull { el ->
|
|
63
|
+
val p = el.asJsonObject
|
|
64
|
+
val name = p.get("name")?.asString ?: return@mapNotNull null
|
|
65
|
+
val path = p.get("path")?.asString ?: return@mapNotNull null
|
|
66
|
+
val node = DefaultMutableTreeNode(TreeNodeData.LocalProject("π $name", path, name))
|
|
67
|
+
|
|
68
|
+
val bs = ForgeClient.get().request("/api/project-sessions?projectPath=${URLEncoder.encode(path, "UTF-8")}")
|
|
69
|
+
val boundId = bs.data?.asJsonObject?.get("fixedSessionId")?.takeUnless { it.isJsonNull }?.asString
|
|
70
|
+
|
|
71
|
+
val ss = ForgeClient.get().request("/api/claude-sessions/${URLEncoder.encode(name, "UTF-8")}")
|
|
72
|
+
val sessions = ss.data?.takeIf { it.isJsonArray }?.asJsonArray?.toList() ?: emptyList()
|
|
73
|
+
|
|
74
|
+
sessions.take(10).forEach { sEl ->
|
|
75
|
+
val s = sEl.asJsonObject
|
|
76
|
+
val sid = s.get("sessionId")?.asString ?: return@forEach
|
|
77
|
+
val mtime = s.get("modified")?.asString ?: ""
|
|
78
|
+
val isBound = sid == boundId
|
|
79
|
+
val mark = if (isBound) "β
" else "Β·"
|
|
80
|
+
val pretty = mtime.take(19).replace('T', ' ')
|
|
81
|
+
node.add(DefaultMutableTreeNode(
|
|
82
|
+
TreeNodeData.ClaudeSession("$mark ${sid.take(8)} $pretty", path, name, sid, isBound),
|
|
83
|
+
))
|
|
84
|
+
}
|
|
85
|
+
node.add(DefaultMutableTreeNode(TreeNodeData.NewSession("β New sessionβ¦", path, name)))
|
|
86
|
+
node
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
override fun onDoubleClick(data: TreeNodeData, node: DefaultMutableTreeNode) {
|
|
91
|
+
when (data) {
|
|
92
|
+
is TreeNodeData.ClaudeSession -> resumeClaudeSession(data)
|
|
93
|
+
is TreeNodeData.NewSession -> promptAgentAndLaunch(data.projectPath, data.projectName, resumeSessionId = null)
|
|
94
|
+
is TreeNodeData.LocalProject -> promptAgentAndLaunch(data.projectPath, data.projectName, resumeSessionId = null)
|
|
95
|
+
else -> {}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
override fun contextActions(data: TreeNodeData, node: DefaultMutableTreeNode): List<AnAction> = when (data) {
|
|
100
|
+
is TreeNodeData.LocalProject -> buildList {
|
|
101
|
+
add(buildOpenWithGroup(data.projectPath, data.projectName, resumeSessionId = null, label = "Open With (fresh)"))
|
|
102
|
+
add(act("New Session⦠(pick agent)", AllIcons.General.Add) { promptAgentAndLaunch(data.projectPath, data.projectName, resumeSessionId = null) })
|
|
103
|
+
add(act("Plain Terminal Here", AllIcons.Debugger.Console) { openPlainTerminal(data.projectPath, data.projectName) })
|
|
104
|
+
}
|
|
105
|
+
is TreeNodeData.ClaudeSession -> buildList {
|
|
106
|
+
add(act("Resume (claude)", AllIcons.Actions.Execute) { resumeClaudeSession(data) })
|
|
107
|
+
add(buildOpenWithGroup(data.projectPath, data.projectName, resumeSessionId = data.sessionId, label = "Resume Withβ¦"))
|
|
108
|
+
if (!data.isBound) add(act("Pin as Default Session", AllIcons.Nodes.Favorite) { bindSession(data) })
|
|
109
|
+
}
|
|
110
|
+
is TreeNodeData.NewSession -> listOf(
|
|
111
|
+
buildOpenWithGroup(data.projectPath, data.projectName, resumeSessionId = null, label = "Open With (fresh)"),
|
|
112
|
+
act("New Session⦠(pick agent)", AllIcons.General.Add) { promptAgentAndLaunch(data.projectPath, data.projectName, resumeSessionId = null) },
|
|
113
|
+
)
|
|
114
|
+
else -> emptyList()
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Submenu listing each cached agent β clicking one launches with the given resume mode. */
|
|
118
|
+
private fun buildOpenWithGroup(projectPath: String, projectName: String, resumeSessionId: String?, label: String): DefaultActionGroup {
|
|
119
|
+
val group = DefaultActionGroup(label, true)
|
|
120
|
+
group.isPopup = true
|
|
121
|
+
val ags = agentsCache
|
|
122
|
+
if (ags.isEmpty()) {
|
|
123
|
+
group.add(act("(loading agents β try again)", null) {})
|
|
124
|
+
} else {
|
|
125
|
+
for ((id, name, type) in ags) {
|
|
126
|
+
val item = if (type != null) "$name ($type)" else name
|
|
127
|
+
group.add(act(item, null) { launchAgent(projectPath, projectName, id, name, resumeSessionId) })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return group
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Resume a specific claude session β picks the first claude-type agent automatically. */
|
|
134
|
+
private fun resumeClaudeSession(s: TreeNodeData.ClaudeSession) {
|
|
135
|
+
runBg(project, "Resuming ${s.sessionId.take(8)}") {
|
|
136
|
+
val ar = ForgeClient.get().request("/api/agents")
|
|
137
|
+
val agents = ar.data?.asJsonObject?.getAsJsonArray("agents")?.toList()?.map { it.asJsonObject } ?: emptyList()
|
|
138
|
+
val claude = agents.firstOrNull {
|
|
139
|
+
val t = it.get("type")?.asString ?: it.get("cliType")?.asString
|
|
140
|
+
t == "claude-code"
|
|
141
|
+
} ?: agents.firstOrNull { it.get("id")?.asString?.startsWith("claude") == true }
|
|
142
|
+
if (claude == null) {
|
|
143
|
+
ApplicationManager.getApplication().invokeLater {
|
|
144
|
+
notify(project, "Forge: no claude agent configured β open New Sessionβ¦ to pick another agent.", com.intellij.notification.NotificationType.WARNING)
|
|
145
|
+
}
|
|
146
|
+
return@runBg
|
|
147
|
+
}
|
|
148
|
+
launchAgent(s.projectPath, s.projectName,
|
|
149
|
+
agentId = claude.get("id").asString,
|
|
150
|
+
agentName = claude.get("name")?.asString ?: "claude",
|
|
151
|
+
resumeSessionId = s.sessionId)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Show an agent picker, then launch with the given resume mode. */
|
|
156
|
+
private fun promptAgentAndLaunch(projectPath: String, projectName: String, resumeSessionId: String?) {
|
|
157
|
+
runBg(project, "Loading agents") {
|
|
158
|
+
val ar = ForgeClient.get().request("/api/agents")
|
|
159
|
+
if (!ar.ok || ar.data == null || !ar.data.isJsonObject) {
|
|
160
|
+
ApplicationManager.getApplication().invokeLater {
|
|
161
|
+
notify(project, "Forge: failed to load agents β ${ar.error ?: "unknown"}", com.intellij.notification.NotificationType.ERROR)
|
|
162
|
+
}
|
|
163
|
+
return@runBg
|
|
164
|
+
}
|
|
165
|
+
val agents = ar.data.asJsonObject.getAsJsonArray("agents")?.toList()?.mapNotNull { el ->
|
|
166
|
+
val o = el.asJsonObject
|
|
167
|
+
if (o.get("enabled")?.asBoolean == false) return@mapNotNull null
|
|
168
|
+
val id = o.get("id")?.asString ?: return@mapNotNull null
|
|
169
|
+
val name = o.get("name")?.asString ?: id
|
|
170
|
+
Triple(id, name, o.get("cliType")?.asString)
|
|
171
|
+
}.orEmpty()
|
|
172
|
+
if (agents.isEmpty()) {
|
|
173
|
+
ApplicationManager.getApplication().invokeLater {
|
|
174
|
+
notify(project, "Forge: no enabled agents β configure one in the Forge web UI.", com.intellij.notification.NotificationType.WARNING)
|
|
175
|
+
}
|
|
176
|
+
return@runBg
|
|
177
|
+
}
|
|
178
|
+
ApplicationManager.getApplication().invokeLater {
|
|
179
|
+
val labels = agents.map { (_, name, type) -> if (type != null) "$name ($type)" else name }.toTypedArray()
|
|
180
|
+
val choice = Messages.showEditableChooseDialog(
|
|
181
|
+
"Choose an agent to launch in $projectName",
|
|
182
|
+
if (resumeSessionId != null) "Forge: Resume With Agent" else "Forge: New Session",
|
|
183
|
+
null, labels, labels[0], null,
|
|
184
|
+
) ?: return@invokeLater
|
|
185
|
+
val idx = labels.indexOf(choice).let { if (it < 0) agents.indexOfFirst { (_, n, _) -> n == choice } else it }
|
|
186
|
+
if (idx < 0) return@invokeLater
|
|
187
|
+
val (agentId, agentName, _) = agents[idx]
|
|
188
|
+
launchAgent(projectPath, projectName, agentId, agentName, resumeSessionId)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Resolve the agent's CLI command + env, build an argv, and spawn it directly
|
|
194
|
+
* as the pty process β no intermediate shell, so the user sees the agent's UI
|
|
195
|
+
* immediately with no leftover prompt fragments. */
|
|
196
|
+
private fun launchAgent(projectPath: String, projectName: String, agentId: String, agentName: String, resumeSessionId: String?) {
|
|
197
|
+
runBg(project, "Launching $agentName") {
|
|
198
|
+
val rr = ForgeClient.get().request("/api/agents?resolve=${URLEncoder.encode(agentId, "UTF-8")}")
|
|
199
|
+
val resolved = rr.data?.asJsonObject
|
|
200
|
+
if (!rr.ok || resolved == null) {
|
|
201
|
+
ApplicationManager.getApplication().invokeLater {
|
|
202
|
+
notify(project, "Forge: cannot resolve $agentName β ${rr.error ?: "unknown"}", com.intellij.notification.NotificationType.ERROR)
|
|
203
|
+
}
|
|
204
|
+
return@runBg
|
|
205
|
+
}
|
|
206
|
+
val cliCmd = resolved.get("cliCmd")?.asString ?: agentId
|
|
207
|
+
val supportsSession = resolved.get("supportsSession")?.asBoolean == true
|
|
208
|
+
val cliType = resolved.get("cliType")?.asString
|
|
209
|
+
// For claude, the API returns `-c` which is `--continue` (zero-arg) β passing a
|
|
210
|
+
// session id after `-c` makes claude treat the id as initial prompt text and
|
|
211
|
+
// resume the *most recent* session instead. Force `--resume` for specific-session
|
|
212
|
+
// launches; this matches what forge's web UI does (see WebTerminal.tsx).
|
|
213
|
+
val resumeFlag = if (cliType == "claude-code" && !resumeSessionId.isNullOrBlank()) {
|
|
214
|
+
"--resume"
|
|
215
|
+
} else {
|
|
216
|
+
resolved.get("resumeFlag")?.asString ?: "--resume"
|
|
217
|
+
}
|
|
218
|
+
val envObj = resolved.getAsJsonObject("env")
|
|
219
|
+
val envMap = mutableMapOf<String, String>()
|
|
220
|
+
envObj?.entrySet()?.forEach { (k, v) -> if (!v.isJsonNull) envMap[k] = v.asString }
|
|
221
|
+
|
|
222
|
+
// The resolve endpoint returns `model` separately from `env`. For claude this
|
|
223
|
+
// becomes `--model <name>`; without it the agent silently falls back to claude's
|
|
224
|
+
// default (opus), so a "sonnet" profile would appear to do nothing.
|
|
225
|
+
val model = resolved.get("model")?.takeUnless { it.isJsonNull }?.asString
|
|
226
|
+
val modelFlag = if (cliType == "claude-code" && !model.isNullOrBlank()) {
|
|
227
|
+
" --model " + shellQuote(model)
|
|
228
|
+
} else ""
|
|
229
|
+
|
|
230
|
+
val cmdLine = buildString {
|
|
231
|
+
append(cliCmd)
|
|
232
|
+
if (supportsSession && !resumeSessionId.isNullOrBlank()) {
|
|
233
|
+
append(' ').append(resumeFlag).append(' ').append(shellQuote(resumeSessionId))
|
|
234
|
+
}
|
|
235
|
+
append(modelFlag)
|
|
236
|
+
}
|
|
237
|
+
// Use the user's login shell so .zprofile/.bash_profile gets sourced β IDEA's
|
|
238
|
+
// inherited PATH usually misses /opt/homebrew/bin, ~/.claude/local, etc. where
|
|
239
|
+
// CLI agents actually live. `exec` makes the shell hand off the pty to the agent
|
|
240
|
+
// so when the agent quits the tab closes; if exec fails (cmd not in PATH) we
|
|
241
|
+
// fall through to a diagnostic so the error stays visible.
|
|
242
|
+
val userShell = System.getenv("SHELL")?.takeIf { it.isNotBlank() } ?: "/bin/zsh"
|
|
243
|
+
val script = """
|
|
244
|
+
exec $cmdLine
|
|
245
|
+
echo
|
|
246
|
+
echo "[forge: failed to launch β '$cliCmd' not found in PATH]"
|
|
247
|
+
echo "[forge: PATH=${'$'}PATH]"
|
|
248
|
+
echo
|
|
249
|
+
echo "(this terminal will close in 30s)"
|
|
250
|
+
sleep 30
|
|
251
|
+
""".trimIndent()
|
|
252
|
+
val shellCmd = listOf(userShell, "-l", "-c", script)
|
|
253
|
+
|
|
254
|
+
ApplicationManager.getApplication().invokeLater {
|
|
255
|
+
spawnInTerminalTab(projectPath, "forge: $projectName ($agentName)", shellCmd, envMap)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Spawn `shellCmd` as the pty's primary process by intercepting
|
|
261
|
+
* [LocalTerminalDirectRunner.configureStartupOptions]. */
|
|
262
|
+
private fun spawnInTerminalTab(workingDir: String, tabName: String, shellCmd: List<String>, extraEnv: Map<String, String>) {
|
|
263
|
+
val runner = object : LocalTerminalDirectRunner(project) {
|
|
264
|
+
// Skip RC-file injection / command markers β our pty process isn't a shell.
|
|
265
|
+
override fun enableShellIntegration(): Boolean = false
|
|
266
|
+
|
|
267
|
+
override fun configureStartupOptions(baseOptions: ShellStartupOptions): ShellStartupOptions {
|
|
268
|
+
val mergedEnv = HashMap<String, String>().apply {
|
|
269
|
+
baseOptions.envVariables?.let { putAll(it) }
|
|
270
|
+
putAll(extraEnv)
|
|
271
|
+
putIfAbsent("TERM", "xterm-256color")
|
|
272
|
+
}
|
|
273
|
+
return baseOptions.builder()
|
|
274
|
+
.shellCommand(shellCmd)
|
|
275
|
+
.workingDirectory(baseOptions.workingDirectory ?: workingDir)
|
|
276
|
+
.envVariables(mergedEnv)
|
|
277
|
+
.build()
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
override fun getDefaultTabTitle(): String = tabName
|
|
281
|
+
}
|
|
282
|
+
val state = TerminalTabState().apply {
|
|
283
|
+
myTabName = tabName
|
|
284
|
+
myWorkingDirectory = workingDir
|
|
285
|
+
}
|
|
286
|
+
TerminalToolWindowManager.getInstance(project).createNewSession(runner, state)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private fun openPlainTerminal(projectPath: String, projectName: String) {
|
|
290
|
+
TerminalToolWindowManager.getInstance(project)
|
|
291
|
+
.createShellWidget(projectPath, "forge: $projectName", false, true)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private fun bindSession(s: TreeNodeData.ClaudeSession) {
|
|
295
|
+
runApi(project, "Pin ${s.sessionId.take(8)}", {
|
|
296
|
+
ForgeClient.get().request(
|
|
297
|
+
"/api/project-sessions",
|
|
298
|
+
method = "POST",
|
|
299
|
+
body = mapOf("projectPath" to s.projectPath, "fixedSessionId" to s.sessionId),
|
|
300
|
+
)
|
|
301
|
+
}) { refresh() }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private fun shellQuote(s: String): String = "'" + s.replace("'", "'\\''") + "'"
|
|
305
|
+
|
|
306
|
+
private fun act(name: String, icon: javax.swing.Icon?, run: () -> Unit) = object : AnAction(name, null, icon) {
|
|
307
|
+
override fun actionPerformed(e: AnActionEvent) = run()
|
|
308
|
+
}
|
|
309
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
package com.aion0.forge.ui.toolwindow
|
|
2
|
+
|
|
3
|
+
/** Typed payloads attached to DefaultMutableTreeNode.userObject so click /
|
|
4
|
+
* right-click handlers can branch on what was clicked. The `label` is what
|
|
5
|
+
* ends up rendered (we just call .toString()). */
|
|
6
|
+
sealed class TreeNodeData(val label: String) {
|
|
7
|
+
override fun toString(): String = label
|
|
8
|
+
|
|
9
|
+
// ββ Workspaces tab βββββββββββββββββββββββββββββββββββββ
|
|
10
|
+
class Workspace(label: String, val workspaceId: String, val projectName: String, val daemonActive: Boolean) : TreeNodeData(label)
|
|
11
|
+
class Smith(label: String, val workspaceId: String, val agentId: String, val agentLabel: String, val taskStatus: String, val paused: Boolean, val tmuxSession: String?) : TreeNodeData(label)
|
|
12
|
+
// ββ Terminals tab ββββββββββββββββββββββββββββββββββββββ
|
|
13
|
+
/** A forge project (from /api/projects). Expand β list of sessions; right-click β new terminal. */
|
|
14
|
+
class LocalProject(label: String, val projectPath: String, val projectName: String) : TreeNodeData(label)
|
|
15
|
+
/** A claude session belonging to a project. Double-click β resume in IDE terminal. */
|
|
16
|
+
class ClaudeSession(label: String, val projectPath: String, val projectName: String, val sessionId: String, val isBound: Boolean) : TreeNodeData(label)
|
|
17
|
+
/** "+ New sessionβ¦" leaf shown under each project. */
|
|
18
|
+
class NewSession(label: String, val projectPath: String, val projectName: String) : TreeNodeData(label)
|
|
19
|
+
|
|
20
|
+
// ββ Pipelines tab ββββββββββββββββββββββββββββββββββββββ
|
|
21
|
+
class PipelineProject(label: String, val projectPath: String, val projectName: String) : TreeNodeData(label)
|
|
22
|
+
class PipelineBinding(label: String, val projectPath: String, val projectName: String, val workflowName: String, val enabled: Boolean) : TreeNodeData(label)
|
|
23
|
+
class PipelineRun(label: String, val pipelineId: String, val workflowName: String, val status: String) : TreeNodeData(label)
|
|
24
|
+
class PipelineNode(label: String, val pipelineId: String, val nodeName: String, val status: String, val error: String?, val taskId: String?) : TreeNodeData(label)
|
|
25
|
+
|
|
26
|
+
// ββ Docs tab βββββββββββββββββββββββββββββββββββββββββββ
|
|
27
|
+
class DocRoot(label: String, val rootIdx: Int, val rootPath: String, val rootName: String) : TreeNodeData(label)
|
|
28
|
+
class DocDir(label: String, val rootIdx: Int, val rootPath: String, val relPath: String) : TreeNodeData(label)
|
|
29
|
+
class DocFile(label: String, val rootIdx: Int, val rootPath: String, val relPath: String, val fileType: String?) : TreeNodeData(label)
|
|
30
|
+
|
|
31
|
+
// ββ Misc βββββββββββββββββββββββββββββββββββββββββββββββ
|
|
32
|
+
class Hint(label: String) : TreeNodeData(label)
|
|
33
|
+
}
|