Compare commits
316
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c90bdfbe42 | ||
|
|
27b6e647c1 | ||
|
|
0631e69ef0 | ||
|
|
f98c637cd9 | ||
|
|
554182d956 | ||
|
|
391efc72d2 | ||
|
|
cf582be306 | ||
|
|
3403224dec | ||
|
|
a9c3cfe6d0 | ||
|
|
3ef1decbdd | ||
|
|
b22539bd5b | ||
|
|
8f0d73d89c | ||
|
|
c3e2faa7ee | ||
|
|
6ccd79baa0 | ||
|
|
4932cbf3b3 | ||
|
|
1ea463f6cf | ||
|
|
7c27a897ac | ||
|
|
a9717d01c8 | ||
|
|
35a6ff9d0f | ||
|
|
474000935a | ||
|
|
5f8cc2a140 | ||
|
|
432967fe64 | ||
|
|
2512b8be42 | ||
|
|
e3556bd847 | ||
|
|
ce0e4e6820 | ||
|
|
fa3d6c82b2 | ||
|
|
ebe25a1a55 | ||
|
|
923911f631 | ||
|
|
8cbe8510ac | ||
|
|
cd5bbcfcb1 | ||
|
|
cbbf526fc4 | ||
|
|
9f5b71d563 | ||
|
|
de603ccd20 | ||
|
|
c6af78e9dd | ||
|
|
7ab503e4f5 | ||
|
|
eaf11a7696 | ||
|
|
4738233580 | ||
|
|
cb25320ea0 | ||
|
|
ff52dd9812 | ||
|
|
f3a5cad789 | ||
|
|
fffd2a55f3 | ||
|
|
563a35d2ec | ||
|
|
9dead5652e | ||
|
|
cd2184b128 | ||
|
|
1b2e5f5f05 | ||
|
|
2c8956e634 | ||
|
|
f2c0882c36 | ||
|
|
c170cd9d60 | ||
|
|
ebf943bf30 | ||
|
|
645aa2dea0 | ||
|
|
3a2513c78d | ||
|
|
7cb52927f2 | ||
|
|
2724447a51 | ||
|
|
142e9a4870 | ||
|
|
e5c17606d0 | ||
|
|
979bfe2fbe | ||
|
|
8f96027878 | ||
|
|
b6f60bfbae | ||
|
|
a8a335eadb | ||
|
|
048e273ff9 | ||
|
|
beef2049ed | ||
|
|
c30592e6b6 | ||
|
|
abae81a0ce | ||
|
|
66a568818a | ||
|
|
f3d6146884 | ||
|
|
35ae01db93 | ||
|
|
8a05ef2eb7 | ||
|
|
ad71147bd9 | ||
|
|
e9c3882cea | ||
|
|
c9562ab5eb | ||
|
|
bc87ddee06 | ||
|
|
554c9ebb31 | ||
|
|
af5ad1a227 | ||
|
|
8304ef9a49 | ||
|
|
cdba6d07e3 | ||
|
|
ea8c42bfb2 | ||
|
|
e0edbc09d2 | ||
|
|
e9e98e0c49 | ||
|
|
02f1844650 | ||
|
|
1b7ec1f54c | ||
|
|
ee61cea64f | ||
|
|
7e21371e47 | ||
|
|
52ae636dbb | ||
|
|
9d93e6e60e | ||
|
|
babdc678e0 | ||
|
|
9ba1078386 | ||
|
|
f0a1774e4d | ||
|
|
ac720eb266 | ||
|
|
0ba83c3d8f | ||
|
|
e8e98baf4d | ||
|
|
b286323c9c | ||
|
|
71ec7e073c | ||
|
|
46bae13212 | ||
|
|
20d303278d | ||
|
|
589e0144e2 | ||
|
|
0061378193 | ||
|
|
1cf4825b1d | ||
|
|
f799aaf564 | ||
|
|
5af5ceb9a8 | ||
|
|
fbdcec6f69 | ||
|
|
6af4cda650 | ||
|
|
ab4beea2d9 | ||
|
|
44ef5619bf | ||
|
|
6adb5dabf0 | ||
|
|
e9c9e1c647 | ||
|
|
4167b21fe9 | ||
|
|
b9f8f02e11 | ||
|
|
f36b7808a7 | ||
|
|
c2d427691d | ||
|
|
50b862b1fa | ||
|
|
4668cf8ece | ||
|
|
638155de03 | ||
|
|
1c0fff5507 | ||
|
|
10c9db6d87 | ||
|
|
b339fecad3 | ||
|
|
3c10a3a08a | ||
|
|
fd79c8116a | ||
|
|
cb8b93f178 | ||
|
|
60e57fde31 | ||
|
|
8d35f7f8c8 | ||
|
|
bc0fd6ee60 | ||
|
|
ed5a088299 | ||
|
|
9c77965c51 | ||
|
|
49df4c2e9e | ||
|
|
65d7186e1c | ||
|
|
47654aa434 | ||
|
|
6ca2ab1707 | ||
|
|
b0b0160ccb | ||
|
|
c4cd8a691e | ||
|
|
0381a571f2 | ||
|
|
8f3d47c49e | ||
|
|
ba0c54759b | ||
|
|
78912e7925 | ||
|
|
fef9be6c01 | ||
|
|
5387b29049 | ||
|
|
88afd0df3f | ||
|
|
df51aa7be9 | ||
|
|
ac8a1b319d | ||
|
|
17a57269f7 | ||
|
|
43bfda5a60 | ||
|
|
6ffbca3596 | ||
|
|
2cde58b42f | ||
|
|
721c34aa11 | ||
|
|
a7d8b5e22f | ||
|
|
2f695e1354 | ||
|
|
1e30b9ade1 | ||
|
|
b4d4b0712c | ||
|
|
4df8fdb7dc | ||
|
|
b949cffb9e | ||
|
|
25653abcf5 | ||
|
|
f78aa13b36 | ||
|
|
0dc10ffe76 | ||
|
|
0543f66469 | ||
|
|
4a2f7e98b4 | ||
|
|
8c151a5fb2 | ||
|
|
8d0886f9b7 | ||
|
|
9f82400f0a | ||
|
|
059316cbac | ||
|
|
0b1c9e15cf | ||
|
|
e30084ea24 | ||
|
|
bb898a75f3 | ||
|
|
c8e90af91c | ||
|
|
d0df60ac87 | ||
|
|
25244803a9 | ||
|
|
92b41a8742 | ||
|
|
508262bbac | ||
|
|
002f1b466b | ||
|
|
bc27bffbae | ||
|
|
bbf622ed9b | ||
|
|
0c46fb993a | ||
|
|
be554d0ede | ||
|
|
4fda393378 | ||
|
|
a1f3690aaf | ||
|
|
4ce432c227 | ||
|
|
ba7fd68004 | ||
|
|
c3d008d07f | ||
|
|
c355f67133 | ||
|
|
b5c9970e28 | ||
|
|
22d740a36b | ||
|
|
ccdfec8257 | ||
|
|
30f91176ae | ||
|
|
a771d29210 | ||
|
|
913421e244 | ||
|
|
2e7c5f7f58 | ||
|
|
dba3d8c249 | ||
|
|
4c8217385a | ||
|
|
758bacf3fc | ||
|
|
0af91296df | ||
|
|
370edfa763 | ||
|
|
e7086512e7 | ||
|
|
aca8d39886 | ||
|
|
6ae7c3eb97 | ||
|
|
a73eb6284f | ||
|
|
57efb615f6 | ||
|
|
fe26dab6a8 | ||
|
|
8eecd9961e | ||
|
|
ac2eb01bda | ||
|
|
1576380080 | ||
|
|
85feb433ae | ||
|
|
a11933b7bf | ||
|
|
fa40eb7378 | ||
|
|
b680266990 | ||
|
|
ded1f050b2 | ||
|
|
a58ebfed93 | ||
|
|
31b429a840 | ||
|
|
12bd68a2af | ||
|
|
f765d415d6 | ||
|
|
d3e3010245 | ||
|
|
7a0dbbf7f2 | ||
|
|
84fa4a3076 | ||
|
|
eafeac5c05 | ||
|
|
f96f94db8f | ||
|
|
efb54750ed | ||
|
|
9e59121eec | ||
|
|
4a5c3b9982 | ||
|
|
443800bd79 | ||
|
|
d55d92d58a | ||
|
|
41770a54a7 | ||
|
|
6cf0b200f8 | ||
|
|
25344abe4f | ||
|
|
b52a9085e9 | ||
|
|
8bd943a8ca | ||
|
|
b86335ddf1 | ||
|
|
c348381c5f | ||
|
|
dabdf9fcba | ||
|
|
b76dc06a09 | ||
|
|
23fbfdc5b8 | ||
|
|
ff34727e3c | ||
|
|
e9ede49256 | ||
|
|
8c97ef0616 | ||
|
|
9533672e09 | ||
|
|
b11784e8e3 | ||
|
|
42ffc1a352 | ||
|
|
a50f098c8a | ||
|
|
32d4058c96 | ||
|
|
74a423567f | ||
|
|
b225fbdd69 | ||
|
|
244e8d2927 | ||
|
|
4a1a10c49a | ||
|
|
d607740fea | ||
|
|
0b34d1608f | ||
|
|
075dea509a | ||
|
|
c067f48a24 | ||
|
|
51f96cfb98 | ||
|
|
8f5d96a95b | ||
|
|
3e98b3ab24 | ||
|
|
24ed1ac002 | ||
|
|
3529947497 | ||
|
|
4851ad9101 | ||
|
|
64741a027e | ||
|
|
cdd4213ff5 | ||
|
|
f8973b3802 | ||
|
|
1e2c0f0a38 | ||
|
|
6fa7dc63e4 | ||
|
|
5c0a4c4640 | ||
|
|
8773671afd | ||
|
|
d9815d86c6 | ||
|
|
13009d0f0e | ||
|
|
bbae09bc48 | ||
|
|
c7c294996e | ||
|
|
3c638b08de | ||
|
|
be23a2b539 | ||
|
|
b98f5cb3e5 | ||
|
|
0f5576c77a | ||
|
|
0292f373f7 | ||
|
|
eea337ce36 | ||
|
|
fd3ea64949 | ||
|
|
046c110460 | ||
|
|
6eb65f1057 | ||
|
|
3a68c1dd2f | ||
|
|
fde1954eac | ||
|
|
25435c34b2 | ||
|
|
4a956e9c38 | ||
|
|
2a49814572 | ||
|
|
d1aa56bab3 | ||
|
|
cf322746a7 | ||
|
|
b25e733539 | ||
|
|
5a861976d7 | ||
|
|
bfe4965081 | ||
|
|
9d7ed8c071 | ||
|
|
88a40c1ea7 | ||
|
|
7049b5f1c0 | ||
|
|
c277ad630c | ||
|
|
fc9c62d46b | ||
|
|
3fe560d67e | ||
|
|
b2b33535ae | ||
|
|
4ea6ddcaee | ||
|
|
e9142bb027 | ||
|
|
d11098a478 | ||
|
|
61776b4e0a | ||
|
|
eabd21bccf | ||
|
|
fd54d4f833 | ||
|
|
78b5abb0cf | ||
|
|
6d8b49b75f | ||
|
|
6c20a99d58 | ||
|
|
b1718559df | ||
|
|
540a3025b1 | ||
|
|
3cb4bebd1e | ||
|
|
c0da16658a | ||
|
|
eb4655a9d2 | ||
|
|
0e20baf7c2 | ||
|
|
c76059ec66 | ||
|
|
af8acbfbf2 | ||
|
|
f02168ffd5 | ||
|
|
1ad7845f51 | ||
|
|
f0c272e0b1 | ||
|
|
624f6f19c8 | ||
|
|
08161ae042 | ||
|
|
1e3c7b294d | ||
|
|
49ab7e9c81 | ||
|
|
0cc25d7e1c | ||
|
|
706942ddd7 | ||
|
|
52877abe29 | ||
|
|
43fc3a3735 | ||
|
|
ff1ffb377c | ||
|
|
ba74d3515b |
@@ -9,7 +9,7 @@ on:
|
|||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CARGO_TERM_COLOR: always
|
CARGO_TERM_COLOR: always
|
||||||
|
|||||||
@@ -15,3 +15,6 @@ worktrees/
|
|||||||
|
|
||||||
# `nix run .#dev` scratch state (tmux socket)
|
# `nix run .#dev` scratch state (tmux socket)
|
||||||
/.dev/
|
/.dev/
|
||||||
|
|
||||||
|
# Per-machine Claude Code tool permissions
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ clients — how it opens, the sigils, the key model, the match-and-rank algorith
|
|||||||
the two matchers must agree on, and the fixture table they are tested against.
|
the two matchers must agree on, and the fixture table they are tested against.
|
||||||
Read it before changing either bar or either matcher.
|
Read it before changing either bar or either matcher.
|
||||||
|
|
||||||
|
`docs/STRUCTURED-VIEW.md` is the contract for a structured session (an agent
|
||||||
|
run as a daemon child over ACP instead of in a tmux pane) — the eight
|
||||||
|
transcript row kinds, the two projections and their cursor/replay semantics,
|
||||||
|
the handshake and declined capabilities, and the approval nonce rules. Read
|
||||||
|
it before changing anything that renders a structured session.
|
||||||
|
|
||||||
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
||||||
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
||||||
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
||||||
|
|||||||
Generated
+750
-57
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ repository = "https://git.naps.pt/naps62/maestro"
|
|||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
agent-client-protocol = { version = "2.1", features = ["unstable_tool_call_name"] }
|
||||||
axum = { version = "0.7", features = ["ws"] }
|
axum = { version = "0.7", features = ["ws"] }
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
|
||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
@@ -17,6 +18,7 @@ crossterm = { version = "0.28", features = ["event-stream"] }
|
|||||||
dirs = "5"
|
dirs = "5"
|
||||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
maestro-acp = { path = "crates/maestro-acp" }
|
||||||
maestro-config = { path = "crates/maestro-config" }
|
maestro-config = { path = "crates/maestro-config" }
|
||||||
# `default-features = false` so each consumer opts into `haiku-autoname`
|
# `default-features = false` so each consumer opts into `haiku-autoname`
|
||||||
# through its own feature of the same name (#131); a workspace entry that
|
# through its own feature of the same name (#131); a workspace entry that
|
||||||
@@ -38,9 +40,11 @@ tempfile = "3"
|
|||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] }
|
||||||
tokio-tungstenite = "0.21"
|
tokio-tungstenite = "0.21"
|
||||||
|
tokio-util = { version = "0.7", features = ["compat"] }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
unicode-width = "0.1"
|
||||||
uuid = { version = "1", features = ["v4", "serde"] }
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
ratatui = "0.28"
|
ratatui = "0.28"
|
||||||
vt100 = "0.15"
|
vt100 = "0.15"
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle/
|
||||||
|
/local.properties
|
||||||
|
/.idea/
|
||||||
|
.DS_Store
|
||||||
|
/build/
|
||||||
|
/captures/
|
||||||
|
.externalNativeBuild/
|
||||||
|
.cxx/
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "pt.naps.maestro"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "pt.naps.maestro"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "0.1.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
getByName("debug") {
|
||||||
|
storeFile = file("debug.keystore")
|
||||||
|
storePassword = "android"
|
||||||
|
keyAlias = "androiddebugkey"
|
||||||
|
keyPassword = "android"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.process)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.ui)
|
||||||
|
implementation(libs.androidx.ui.graphics)
|
||||||
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.material3)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
implementation(libs.okhttp)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
testImplementation(libs.okhttp.mockwebserver)
|
||||||
|
testImplementation(libs.turbine)
|
||||||
|
|
||||||
|
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||||
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||||
|
|
||||||
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
debugImplementation(libs.androidx.ui.test.manifest)
|
||||||
|
}
|
||||||
Binary file not shown.
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.kts.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package pt.naps.maestro
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class MainActivityTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createAndroidComposeRule<MainActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun placeholderScreen_showsAppName() {
|
||||||
|
composeTestRule.onNodeWithText("Maestro").assertExists()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:theme="@style/Theme.Maestro">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:theme="@style/Theme.Maestro">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||||
|
<link rel="stylesheet" href="vendor/xterm.css" />
|
||||||
|
<style>
|
||||||
|
html, body, #terminal { height: 100%; margin: 0; padding: 0; background: #121419; overflow: hidden; }
|
||||||
|
.xterm { padding: 4px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="terminal"></div>
|
||||||
|
<script src="vendor/xterm.js"></script>
|
||||||
|
<script src="vendor/addon-fit.js"></script>
|
||||||
|
<script>
|
||||||
|
// Owns the `WS /api/attach/:id` connection directly, the same shape as
|
||||||
|
// web/src/components/terminal/TerminalPane.tsx — PTY bytes in binary
|
||||||
|
// frames both ways, `ClientControl` JSON in text frames. The native
|
||||||
|
// Android side (TerminalWebView.kt) never touches the socket; it only
|
||||||
|
// calls the maestro* bridge functions below for the extra key bar.
|
||||||
|
var params = new URLSearchParams(location.search);
|
||||||
|
var wsUrl = params.get("ws");
|
||||||
|
|
||||||
|
var term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||||
|
fontSize: 13,
|
||||||
|
convertEol: false,
|
||||||
|
scrollback: 2000,
|
||||||
|
theme: {
|
||||||
|
background: "#121419",
|
||||||
|
foreground: "#e6e8ec",
|
||||||
|
cursor: "#5fe3a1",
|
||||||
|
cursorAccent: "#121419",
|
||||||
|
selectionBackground: "#2b6b4f",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
var fitAddon = new FitAddon.FitAddon();
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
var container = document.getElementById("terminal");
|
||||||
|
term.open(container);
|
||||||
|
fitAddon.fit();
|
||||||
|
|
||||||
|
var ws = null;
|
||||||
|
var awaitingReplay = true;
|
||||||
|
var armed = { ctrl: false, alt: false };
|
||||||
|
|
||||||
|
function sendControl(msg) {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendInput(data) {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) ws.send(new TextEncoder().encode(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
awaitingReplay = true;
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
ws.binaryType = "arraybuffer";
|
||||||
|
|
||||||
|
ws.onopen = function () {
|
||||||
|
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = function (ev) {
|
||||||
|
if (typeof ev.data === "string") return; // control frame, nothing to render
|
||||||
|
var replay = awaitingReplay;
|
||||||
|
awaitingReplay = false;
|
||||||
|
term.write(new Uint8Array(ev.data), function () {
|
||||||
|
if (replay) term.scrollToBottom();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = function (ev) {
|
||||||
|
if (ev.code === 1000) return;
|
||||||
|
term.write("\r\n\x1b[31m[disconnected]\x1b[0m\r\n");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl maps to the C0 control range (A-Z, [, \, ], ^, _); Alt prefixes ESC
|
||||||
|
// — the same encoding buildKeyBar/applyModsToChar use on the web client.
|
||||||
|
function applyModsToChar(ch, ctrl, alt) {
|
||||||
|
var out = ch;
|
||||||
|
if (ctrl) {
|
||||||
|
var code = ch.toUpperCase().charCodeAt(0);
|
||||||
|
if (code >= 64 && code <= 95) out = String.fromCharCode(code - 64);
|
||||||
|
}
|
||||||
|
if (alt) out = "\x1b" + out;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSI cursor sequence with the modifier parameter xterm expects:
|
||||||
|
// `1;<mod>` where 3=alt, 5=ctrl, 7=ctrl+alt.
|
||||||
|
function cursorSeq(final, ctrl, alt) {
|
||||||
|
if (!ctrl && !alt) return "\x1b[" + final;
|
||||||
|
var mod = 1 + (alt ? 2 : 0) + (ctrl ? 4 : 0);
|
||||||
|
return "\x1b[1;" + mod + final;
|
||||||
|
}
|
||||||
|
|
||||||
|
term.onData(function (data) {
|
||||||
|
if ((armed.ctrl || armed.alt) && data.length === 1) {
|
||||||
|
var hadCtrl = armed.ctrl;
|
||||||
|
var hadAlt = armed.alt;
|
||||||
|
sendInput(applyModsToChar(data, hadCtrl, hadAlt));
|
||||||
|
armed.ctrl = false;
|
||||||
|
armed.alt = false;
|
||||||
|
if (window.AndroidBridge) {
|
||||||
|
if (hadCtrl) AndroidBridge.onModConsumed("ctrl");
|
||||||
|
if (hadAlt) AndroidBridge.onModConsumed("alt");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sendInput(data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
term.onResize(function (size) {
|
||||||
|
sendControl({ type: "Resize", cols: size.cols, rows: size.rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
container.addEventListener("click", function () {
|
||||||
|
term.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
var ro = new ResizeObserver(function () {
|
||||||
|
try {
|
||||||
|
fitAddon.fit();
|
||||||
|
} catch (e) {
|
||||||
|
// container not measurable yet (e.g. mid-teardown) — next observation retries
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ro.observe(container);
|
||||||
|
|
||||||
|
// Bridge called from TerminalWebViewController via evaluateJavascript —
|
||||||
|
// the native extra key bar's Tab/Esc/arrow buttons and Ctrl/Alt toggles.
|
||||||
|
window.maestroKey = function (key) {
|
||||||
|
var ctrl = armed.ctrl;
|
||||||
|
var alt = armed.alt;
|
||||||
|
var seq;
|
||||||
|
switch (key) {
|
||||||
|
case "Tab": seq = "\t"; break;
|
||||||
|
case "Esc": seq = "\x1b"; break;
|
||||||
|
case "Up": seq = cursorSeq("A", ctrl, alt); break;
|
||||||
|
case "Down": seq = cursorSeq("B", ctrl, alt); break;
|
||||||
|
case "Right": seq = cursorSeq("C", ctrl, alt); break;
|
||||||
|
case "Left": seq = cursorSeq("D", ctrl, alt); break;
|
||||||
|
default: return;
|
||||||
|
}
|
||||||
|
sendInput(seq);
|
||||||
|
armed.ctrl = false;
|
||||||
|
armed.alt = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.maestroToggleMod = function (name) {
|
||||||
|
if (name === "ctrl" || name === "alt") armed[name] = !armed[name];
|
||||||
|
};
|
||||||
|
|
||||||
|
window.maestroFocus = function () {
|
||||||
|
term.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})()));
|
||||||
|
//# sourceMappingURL=addon-fit.js.map
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||||
|
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||||
|
* https://github.com/chjj/term.js
|
||||||
|
* @license MIT
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
* of this software and associated documentation files (the "Software"), to deal
|
||||||
|
* in the Software without restriction, including without limitation the rights
|
||||||
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
* copies of the Software, and to permit persons to whom the Software is
|
||||||
|
* furnished to do so, subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in
|
||||||
|
* all copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
* THE SOFTWARE.
|
||||||
|
*
|
||||||
|
* Originally forked from (with the author's permission):
|
||||||
|
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||||
|
* http://bellard.org/jslinux/
|
||||||
|
* Copyright (c) 2011 Fabrice Bellard
|
||||||
|
* The original design remains. The terminal itself
|
||||||
|
* has been extended to include xterm CSI codes, among
|
||||||
|
* other features.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default styles for xterm.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
.xterm {
|
||||||
|
cursor: text;
|
||||||
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.focus,
|
||||||
|
.xterm:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helpers {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
/**
|
||||||
|
* The z-index of the helpers must be higher than the canvases in order for
|
||||||
|
* IMEs to appear on top.
|
||||||
|
*/
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-helper-textarea {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
left: -9999em;
|
||||||
|
top: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
z-index: -5;
|
||||||
|
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view {
|
||||||
|
/* TODO: Composition position got messed up somewhere */
|
||||||
|
background: #000;
|
||||||
|
color: #FFF;
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .composition-view.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-viewport {
|
||||||
|
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||||
|
background-color: #000;
|
||||||
|
overflow-y: scroll;
|
||||||
|
cursor: default;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-screen canvas {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-scroll-area {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-char-measure-element {
|
||||||
|
display: inline-block;
|
||||||
|
visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -9999em;
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.enable-mouse-events {
|
||||||
|
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.xterm-cursor-pointer,
|
||||||
|
.xterm .xterm-cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm.column-select.focus {
|
||||||
|
/* Column selection mode */
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility:not(.debug),
|
||||||
|
.xterm .xterm-message {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 10;
|
||||||
|
color: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .xterm-accessibility-tree {
|
||||||
|
user-select: text;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm .live-region {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-dim {
|
||||||
|
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||||
|
* explicitly in the generated class and reset to 1 here */
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-underline-1 { text-decoration: underline; }
|
||||||
|
.xterm-underline-2 { text-decoration: double underline; }
|
||||||
|
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||||
|
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||||
|
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||||
|
|
||||||
|
.xterm-overline {
|
||||||
|
text-decoration: overline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||||
|
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||||
|
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||||
|
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||||
|
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||||
|
|
||||||
|
.xterm-strikethrough {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||||
|
z-index: 6;
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||||
|
z-index: 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-overview-ruler {
|
||||||
|
z-index: 8;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-decoration-top {
|
||||||
|
z-index: 2;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,142 @@
|
|||||||
|
package pt.naps.maestro
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.DrawerValue
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.rememberDrawerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pt.naps.maestro.connection.DaemonLink
|
||||||
|
import pt.naps.maestro.connection.DaemonPrefs
|
||||||
|
import pt.naps.maestro.connection.LinkState
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.ui.sidebar.SidebarContent
|
||||||
|
import pt.naps.maestro.ui.sidebar.sessionName
|
||||||
|
import pt.naps.maestro.ui.terminal.TerminalScreen
|
||||||
|
import pt.naps.maestro.ui.theme.MaestroTheme
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
private lateinit var daemonLink: DaemonLink
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
val prefs = DaemonPrefs(applicationContext)
|
||||||
|
intent?.getStringExtra(DaemonPrefs.EXTRA_DAEMON_URL)?.let { prefs.setBaseUrl(it) }
|
||||||
|
daemonLink = DaemonLink(applicationContext, prefs).also { it.start() }
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
MaestroTheme {
|
||||||
|
val linkState by daemonLink.state.collectAsState()
|
||||||
|
val baseUrl by prefs.baseUrl.collectAsState()
|
||||||
|
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var openSession by remember { mutableStateOf<Session?>(null) }
|
||||||
|
|
||||||
|
val session = openSession
|
||||||
|
if (session != null) {
|
||||||
|
TerminalScreen(
|
||||||
|
daemonBaseUrl = baseUrl,
|
||||||
|
sessionId = session.id,
|
||||||
|
sessionTitle = sessionName(session),
|
||||||
|
onBack = { openSession = null },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ModalNavigationDrawer(
|
||||||
|
drawerState = drawerState,
|
||||||
|
drawerContent = {
|
||||||
|
ModalDrawerSheet(modifier = Modifier.width(320.dp)) {
|
||||||
|
SidebarContent(
|
||||||
|
catalogStore = daemonLink.catalog,
|
||||||
|
onOpenSession = { s ->
|
||||||
|
openSession = s
|
||||||
|
scope.launch { drawerState.close() }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("Maestro") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||||
|
Text("☰")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
PlaceholderScreen(modifier = Modifier.padding(innerPadding), linkState = linkState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
daemonLink.close()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PlaceholderScreen(modifier: Modifier = Modifier, linkState: LinkState = LinkState.CONNECTING) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(24.dp),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Maestro",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.placeholder_message),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Daemon: ${linkState.name.lowercase()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun PlaceholderScreenPreview() {
|
||||||
|
MaestroTheme {
|
||||||
|
PlaceholderScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package pt.naps.maestro.api
|
||||||
|
|
||||||
|
import pt.naps.maestro.data.InTheWay
|
||||||
|
|
||||||
|
/** A `/api` failure with the daemon's own error envelope kept intact, so a
|
||||||
|
* caller can branch on [code] and render [unsavedWork] — the kill guard —
|
||||||
|
* rather than re-parsing [detail]. Mirrors the web client's
|
||||||
|
* `ApiRequestError`. */
|
||||||
|
class MaestroApiException(
|
||||||
|
val status: Int,
|
||||||
|
val code: String,
|
||||||
|
val detail: String,
|
||||||
|
val unsavedWork: InTheWay? = null,
|
||||||
|
) : Exception(buildMessage(status, code, detail)) {
|
||||||
|
private companion object {
|
||||||
|
fun buildMessage(status: Int, code: String, detail: String): String {
|
||||||
|
val info = if (code.isNotEmpty()) "$code: $detail" else detail
|
||||||
|
return if (info.isNotEmpty()) "$status — $info" else "$status"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package pt.naps.maestro.api
|
||||||
|
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.Callback
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import pt.naps.maestro.data.ApiError
|
||||||
|
import pt.naps.maestro.data.BranchInfo
|
||||||
|
import pt.naps.maestro.data.CreateSessionRequest
|
||||||
|
import pt.naps.maestro.data.DaemonInfo
|
||||||
|
import pt.naps.maestro.data.DirListing
|
||||||
|
import pt.naps.maestro.data.GitRoot
|
||||||
|
import pt.naps.maestro.data.NameBatch
|
||||||
|
import pt.naps.maestro.data.Project
|
||||||
|
import pt.naps.maestro.data.RecentDirs
|
||||||
|
import pt.naps.maestro.data.RenameSessionRequest
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SetFocusRequest
|
||||||
|
import pt.naps.maestro.data.StructuredAttachmentResponse
|
||||||
|
import pt.naps.maestro.data.StructuredApprovalDecision
|
||||||
|
import pt.naps.maestro.data.StructuredConfigRequest
|
||||||
|
import pt.naps.maestro.data.StructuredPromptRequest
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
|
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
|
||||||
|
private val OCTET_STREAM_MEDIA_TYPE = "application/octet-stream".toMediaType()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed client for the daemon's `/api` surface (REST half). Mirrors
|
||||||
|
* `maestro_protocol::client::DaemonClient` / `web/src/api.ts`.
|
||||||
|
*
|
||||||
|
* There is no authentication layer: reaching the daemon's bind address is
|
||||||
|
* the authorization, same trust model as tmux itself.
|
||||||
|
*/
|
||||||
|
class MaestroClient(
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val httpClient: OkHttpClient = OkHttpClient(),
|
||||||
|
) {
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun listSessions(): List<Session> = get(Routes.SESSIONS)
|
||||||
|
|
||||||
|
suspend fun createSession(request: CreateSessionRequest): Session =
|
||||||
|
post(Routes.SESSIONS, request)
|
||||||
|
|
||||||
|
/** `PATCH /api/sessions/:id`: rename a live session. An empty [name]
|
||||||
|
* clears the custom one, so the card falls back to the command. */
|
||||||
|
suspend fun renameSession(id: Uuid, name: String): Session =
|
||||||
|
patch(Routes.session(id), RenameSessionRequest(name))
|
||||||
|
|
||||||
|
/** `DELETE /api/sessions/:id`. With [force], the daemon overrides the
|
||||||
|
* kill refusal over a worktree holding unsaved work and leaves that
|
||||||
|
* worktree on disk. */
|
||||||
|
suspend fun killSession(id: Uuid, force: Boolean = false) {
|
||||||
|
val query = if (force) listOf(Routes.FORCE_QUERY to "true") else emptyList()
|
||||||
|
executeNoContent(newRequest("DELETE", Routes.session(id), query = query))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/focus`: report whether this client's pane is
|
||||||
|
* the one currently showing [id] — not merely highlighted in a
|
||||||
|
* sidebar. */
|
||||||
|
suspend fun setSessionFocus(id: Uuid, focused: Boolean) {
|
||||||
|
executeNoContent(
|
||||||
|
newRequest(
|
||||||
|
"POST",
|
||||||
|
Routes.sessionFocus(id),
|
||||||
|
body = json.encodeToString(SetFocusRequest(focused)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /api/dirs?path=`: one directory's immediate subdirectories.
|
||||||
|
* [path] may be empty, `~`, relative, or absolute — the daemon resolves
|
||||||
|
* it against its home dir. */
|
||||||
|
suspend fun listDir(path: String): DirListing =
|
||||||
|
get(Routes.DIRS, listOf(Routes.PATH_QUERY to path))
|
||||||
|
|
||||||
|
/** `GET /api/info`: identity + spawn presets of the connected daemon. */
|
||||||
|
suspend fun getDaemonInfo(): DaemonInfo = get(Routes.INFO)
|
||||||
|
|
||||||
|
/** `GET /api/recent-dirs`: recently-used working dirs, newest-first and
|
||||||
|
* deduped by dir. */
|
||||||
|
suspend fun recentDirs(): RecentDirs = get(Routes.RECENT_DIRS)
|
||||||
|
|
||||||
|
/** `GET /api/projects`: every discovered git repository/worktree,
|
||||||
|
* newest-activity-first. */
|
||||||
|
suspend fun listProjects(): List<Project> = get(Routes.PROJECTS)
|
||||||
|
|
||||||
|
/** `GET /api/gitroot?path=`: the enclosing git repo of [path]; absent
|
||||||
|
* when [path] isn't inside a repo. */
|
||||||
|
suspend fun gitRoot(path: String): GitRoot =
|
||||||
|
get(Routes.GITROOT, listOf(Routes.PATH_QUERY to path))
|
||||||
|
|
||||||
|
/** `GET /api/names?count=`: a batch of fresh session handles. */
|
||||||
|
suspend fun fetchNames(count: Int): NameBatch =
|
||||||
|
get(Routes.NAMES, listOf(Routes.COUNT_QUERY to count.toString()))
|
||||||
|
|
||||||
|
/** `GET /api/branches?path=`: one repository's local branches, with
|
||||||
|
* worktree occupancy. */
|
||||||
|
suspend fun listBranches(path: String): List<BranchInfo> =
|
||||||
|
get(Routes.BRANCHES, listOf(Routes.PATH_QUERY to path))
|
||||||
|
|
||||||
|
/** Send a prompt to a structured session's ACP turn. `409
|
||||||
|
* turn_in_flight` when one is already active. */
|
||||||
|
suspend fun sendStructuredPrompt(id: Uuid, prompt: String) {
|
||||||
|
executeNoContent(
|
||||||
|
newRequest(
|
||||||
|
"POST",
|
||||||
|
Routes.structuredPrompt(id),
|
||||||
|
body = json.encodeToString(StructuredPromptRequest(prompt)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload a file attachment for a structured session's next prompt. */
|
||||||
|
suspend fun uploadStructuredAttachment(
|
||||||
|
id: Uuid,
|
||||||
|
fileName: String,
|
||||||
|
contents: ByteArray,
|
||||||
|
): StructuredAttachmentResponse {
|
||||||
|
val url = Routes.httpUrl(baseUrl, Routes.structuredAttachment(id)).toHttpUrl()
|
||||||
|
.newBuilder()
|
||||||
|
.addQueryParameter("name", fileName)
|
||||||
|
.build()
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.post(contents.toRequestBody(OCTET_STREAM_MEDIA_TYPE))
|
||||||
|
.build()
|
||||||
|
return decode(execute(request))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel a structured session's active ACP turn. */
|
||||||
|
suspend fun cancelStructuredTurn(id: Uuid) {
|
||||||
|
executeNoContent(newRequest("POST", Routes.structuredCancel(id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Change one option advertised by a live ACP session. */
|
||||||
|
suspend fun setStructuredConfig(id: Uuid, configId: String, value: JsonElement) {
|
||||||
|
executeNoContent(
|
||||||
|
newRequest(
|
||||||
|
"POST",
|
||||||
|
Routes.structuredConfig(id),
|
||||||
|
body = json.encodeToString(StructuredConfigRequest(configId, value)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer a pending ACP permission request. Always resolves once the
|
||||||
|
* session itself is a live structured session — a nonce that is unknown
|
||||||
|
* or already consumed still answers success. */
|
||||||
|
suspend fun resolveStructuredApproval(
|
||||||
|
id: Uuid,
|
||||||
|
nonce: String,
|
||||||
|
decision: StructuredApprovalDecision,
|
||||||
|
) {
|
||||||
|
executeNoContent(
|
||||||
|
newRequest(
|
||||||
|
"POST",
|
||||||
|
Routes.structuredApproval(id, nonce),
|
||||||
|
body = json.encodeToString(decision),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- plumbing ------------------------------------------------------
|
||||||
|
|
||||||
|
private suspend inline fun <reified T> get(
|
||||||
|
path: String,
|
||||||
|
query: List<Pair<String, String>> = emptyList(),
|
||||||
|
): T = decode(execute(newRequest("GET", path, query = query)))
|
||||||
|
|
||||||
|
private suspend inline fun <reified B, reified T> post(path: String, body: B): T =
|
||||||
|
decode(execute(newRequest("POST", path, body = json.encodeToString(body))))
|
||||||
|
|
||||||
|
private suspend inline fun <reified B, reified T> patch(path: String, body: B): T =
|
||||||
|
decode(execute(newRequest("PATCH", path, body = json.encodeToString(body))))
|
||||||
|
|
||||||
|
private fun newRequest(
|
||||||
|
method: String,
|
||||||
|
path: String,
|
||||||
|
query: List<Pair<String, String>> = emptyList(),
|
||||||
|
body: String? = null,
|
||||||
|
): Request {
|
||||||
|
val urlBuilder = Routes.httpUrl(baseUrl, path).toHttpUrl().newBuilder()
|
||||||
|
for ((key, value) in query) urlBuilder.addQueryParameter(key, value)
|
||||||
|
// OkHttp refuses a null body on POST/PUT/PATCH even when the daemon
|
||||||
|
// expects none (e.g. the structured-turn cancel) — an empty body
|
||||||
|
// satisfies it without implying a JSON payload.
|
||||||
|
val requestBody = when {
|
||||||
|
body != null -> body.toRequestBody(JSON_MEDIA_TYPE)
|
||||||
|
method == "POST" || method == "PUT" || method == "PATCH" -> ByteArray(0).toRequestBody(null)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
return Request.Builder()
|
||||||
|
.url(urlBuilder.build())
|
||||||
|
.method(method, requestBody)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun execute(request: Request): Response =
|
||||||
|
suspendCancellableCoroutine { cont ->
|
||||||
|
val call = httpClient.newCall(request)
|
||||||
|
cont.invokeOnCancellation { call.cancel() }
|
||||||
|
call.enqueue(
|
||||||
|
object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
if (!cont.isCancelled) cont.resumeWithException(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
cont.resume(response)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun executeNoContent(request: Request) {
|
||||||
|
execute(request).use { if (!it.isSuccessful) throw toApiException(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> decode(response: Response): T =
|
||||||
|
response.use {
|
||||||
|
if (!it.isSuccessful) throw toApiException(it)
|
||||||
|
json.decodeFromString<T>(it.body?.string().orEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toApiException(response: Response): MaestroApiException {
|
||||||
|
val text = response.body?.string().orEmpty()
|
||||||
|
val info = runCatching { json.decodeFromString<ApiError>(text) }.getOrNull()?.error
|
||||||
|
return MaestroApiException(
|
||||||
|
status = response.code,
|
||||||
|
code = info?.code ?: "",
|
||||||
|
detail = info?.message ?: text,
|
||||||
|
unsavedWork = info?.unsaved_work,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package pt.naps.maestro.api
|
||||||
|
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okhttp3.WebSocket
|
||||||
|
import okhttp3.WebSocketListener
|
||||||
|
import okio.ByteString
|
||||||
|
import pt.naps.maestro.data.CatalogEvent
|
||||||
|
import pt.naps.maestro.data.ClientControl
|
||||||
|
import pt.naps.maestro.data.StructuredEvent
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
|
||||||
|
private val wsJson = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One frame received on `WS /api/attach/:id`: raw PTY bytes, or a decoded
|
||||||
|
* [ClientControl] text message. */
|
||||||
|
sealed class AttachFrame {
|
||||||
|
data class Pty(val bytes: ByteArray) : AttachFrame()
|
||||||
|
data class Control(val message: ClientControl) : AttachFrame()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A live `WS /api/attach/:id` connection. PTY bytes travel in binary frames
|
||||||
|
* both directions; control messages ([ClientControl]) travel in text
|
||||||
|
* frames. */
|
||||||
|
class AttachConnection internal constructor(
|
||||||
|
private val socket: WebSocket,
|
||||||
|
val frames: Flow<AttachFrame>,
|
||||||
|
) {
|
||||||
|
/** Type raw bytes into the session's pane, as if typed on an attached
|
||||||
|
* terminal. */
|
||||||
|
fun sendInput(bytes: ByteArray) {
|
||||||
|
socket.send(ByteString.of(*bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resize(cols: Int, rows: Int) {
|
||||||
|
sendControl(ClientControl.Resize(cols, rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ping() {
|
||||||
|
sendControl(ClientControl.Ping)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendControl(message: ClientControl) {
|
||||||
|
socket.send(wsJson.encodeToString(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
socket.close(1000, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A live `WS /api/structured/:id` connection: transcript/state snapshots
|
||||||
|
* and live deltas for one structured session. */
|
||||||
|
class StructuredConnection internal constructor(
|
||||||
|
private val socket: WebSocket,
|
||||||
|
val events: Flow<StructuredEvent>,
|
||||||
|
) {
|
||||||
|
fun close() {
|
||||||
|
socket.close(1000, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A live `WS /api/events` connection: catalog snapshot + session deltas. */
|
||||||
|
class EventsConnection internal constructor(
|
||||||
|
private val socket: WebSocket,
|
||||||
|
val events: Flow<CatalogEvent>,
|
||||||
|
) {
|
||||||
|
fun close() {
|
||||||
|
socket.close(1000, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket half of the daemon's `/api` surface. Mirrors the `*_url`
|
||||||
|
* helpers in `crates/maestro-protocol/src/routes.rs`.
|
||||||
|
*
|
||||||
|
* Each `connect*` call opens the socket immediately and returns a handle
|
||||||
|
* whose `Flow` starts buffering frames right away — a slow collector never
|
||||||
|
* misses a frame, it only sees it later. Call `close()` on the returned
|
||||||
|
* connection when done; there is no automatic reconnect.
|
||||||
|
*/
|
||||||
|
class MaestroWebSocket(
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val httpClient: OkHttpClient = OkHttpClient(),
|
||||||
|
) {
|
||||||
|
/** `WS /api/events`: catalog snapshot + session deltas. */
|
||||||
|
fun connectEvents(): EventsConnection {
|
||||||
|
val channel = Channel<CatalogEvent>(Channel.BUFFERED)
|
||||||
|
val socket = open(
|
||||||
|
Routes.eventsWsUrl(baseUrl),
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
runCatching { wsJson.decodeFromString<CatalogEvent>(text) }
|
||||||
|
.onSuccess { channel.trySend(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
channel.close(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
channel.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return EventsConnection(socket, channel.receiveAsFlow())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `WS /api/attach/:id`: raw PTY bytes both directions, control messages
|
||||||
|
* as text JSON. */
|
||||||
|
fun connectAttach(id: Uuid): AttachConnection {
|
||||||
|
val channel = Channel<AttachFrame>(Channel.BUFFERED)
|
||||||
|
val socket = open(
|
||||||
|
Routes.attachWsUrl(baseUrl, id),
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
runCatching { wsJson.decodeFromString<ClientControl>(text) }
|
||||||
|
.onSuccess { channel.trySend(AttachFrame.Control(it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
|
||||||
|
channel.trySend(AttachFrame.Pty(bytes.toByteArray()))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
channel.close(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
channel.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return AttachConnection(socket, channel.receiveAsFlow())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `WS /api/structured/:id`: transcript/state snapshots and live deltas
|
||||||
|
* for one structured session. */
|
||||||
|
fun connectStructured(id: Uuid): StructuredConnection {
|
||||||
|
val channel = Channel<StructuredEvent>(Channel.BUFFERED)
|
||||||
|
val socket = open(
|
||||||
|
Routes.structuredWsUrl(baseUrl, id),
|
||||||
|
object : WebSocketListener() {
|
||||||
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||||
|
runCatching { wsJson.decodeFromString<StructuredEvent>(text) }
|
||||||
|
.onSuccess { channel.trySend(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||||
|
channel.close(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||||
|
channel.close()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return StructuredConnection(socket, channel.receiveAsFlow())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun open(url: String, listener: WebSocketListener): WebSocket {
|
||||||
|
val request = Request.Builder().url(url).build()
|
||||||
|
return httpClient.newWebSocket(request, listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package pt.naps.maestro.api
|
||||||
|
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
|
||||||
|
/** The daemon's `/api` surface: paths, and the scheme swap needed to reach
|
||||||
|
* the WebSocket endpoints. Mirrors `maestro-protocol::routes` /
|
||||||
|
* `web/src/routes.ts`. */
|
||||||
|
object Routes {
|
||||||
|
const val API_PREFIX = "/api"
|
||||||
|
|
||||||
|
const val SESSIONS = "/api/sessions"
|
||||||
|
const val INFO = "/api/info"
|
||||||
|
const val DIRS = "/api/dirs"
|
||||||
|
const val RECENT_DIRS = "/api/recent-dirs"
|
||||||
|
const val PROJECTS = "/api/projects"
|
||||||
|
const val GITROOT = "/api/gitroot"
|
||||||
|
const val NAMES = "/api/names"
|
||||||
|
const val BRANCHES = "/api/branches"
|
||||||
|
const val EVENTS = "/api/events"
|
||||||
|
|
||||||
|
/** Name of the query parameter [DIRS], [GITROOT] and [BRANCHES] take. */
|
||||||
|
const val PATH_QUERY = "path"
|
||||||
|
|
||||||
|
/** Name of the query parameter `DELETE /api/sessions/:id` takes:
|
||||||
|
* `force=true` overrides the kill refusal over a guarded worktree. */
|
||||||
|
const val FORCE_QUERY = "force"
|
||||||
|
|
||||||
|
/** Name of the query parameter [NAMES] takes. */
|
||||||
|
const val COUNT_QUERY = "count"
|
||||||
|
|
||||||
|
fun session(id: Uuid): String = "$SESSIONS/$id"
|
||||||
|
|
||||||
|
fun sessionFocus(id: Uuid): String = "${session(id)}/focus"
|
||||||
|
|
||||||
|
fun attach(id: Uuid): String = "$API_PREFIX/attach/$id"
|
||||||
|
|
||||||
|
/** `WS /api/structured/:id` — transcript/state snapshots and live
|
||||||
|
* deltas. */
|
||||||
|
fun structured(id: Uuid): String = "$API_PREFIX/structured/$id"
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/prompt` — send an ACP prompt. */
|
||||||
|
fun structuredPrompt(id: Uuid): String = "${session(id)}/structured/prompt"
|
||||||
|
|
||||||
|
fun structuredAttachment(id: Uuid): String = "${session(id)}/structured/attachments"
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/cancel` — cancel an ACP turn. */
|
||||||
|
fun structuredCancel(id: Uuid): String = "${session(id)}/structured/cancel"
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/config` — change an ACP session
|
||||||
|
* option. */
|
||||||
|
fun structuredConfig(id: Uuid): String = "${session(id)}/structured/config"
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/approvals/:nonce` — answer a
|
||||||
|
* permission request. */
|
||||||
|
fun structuredApproval(id: Uuid, nonce: String): String =
|
||||||
|
"${session(id)}/structured/approvals/$nonce"
|
||||||
|
|
||||||
|
/** Strip a trailing slash so `{base}{path}` never doubles up. */
|
||||||
|
fun normalizeBase(base: String): String = base.trimEnd('/')
|
||||||
|
|
||||||
|
/** Join a daemon base URL and one of this object's paths. */
|
||||||
|
fun httpUrl(base: String, path: String): String = "${normalizeBase(base)}$path"
|
||||||
|
|
||||||
|
/** Swap an HTTP base URL for its WebSocket equivalent: `http://` becomes
|
||||||
|
* `ws://`, `https://` becomes `wss://`; a base already `ws(s)://` passes
|
||||||
|
* through. */
|
||||||
|
fun wsBase(httpBase: String): String {
|
||||||
|
val base = normalizeBase(httpBase)
|
||||||
|
return when {
|
||||||
|
base.startsWith("https://") -> "wss://${base.removePrefix("https://")}"
|
||||||
|
base.startsWith("http://") -> "ws://${base.removePrefix("http://")}"
|
||||||
|
base.startsWith("wss://") || base.startsWith("ws://") -> base
|
||||||
|
else -> throw IllegalArgumentException(
|
||||||
|
"daemon url has no supported scheme (want http/https/ws/wss): $httpBase",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun eventsWsUrl(base: String): String = "${wsBase(base)}$EVENTS"
|
||||||
|
|
||||||
|
fun attachWsUrl(base: String, id: Uuid): String = "${wsBase(base)}${attach(id)}"
|
||||||
|
|
||||||
|
fun structuredWsUrl(base: String, id: Uuid): String = "${wsBase(base)}${structured(id)}"
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exponential backoff for daemon reconnect attempts: doubles from
|
||||||
|
* [initialMs] up to [maxMs], then holds. A phone's radio swinging between
|
||||||
|
* wifi and cellular (or losing signal outright) is a bigger, longer-lived
|
||||||
|
* change than the LAN blips the web/TUI clients ride out with a flat poll,
|
||||||
|
* so this backs off instead of hammering the daemon while nothing changed.
|
||||||
|
*/
|
||||||
|
class Backoff(
|
||||||
|
private val initialMs: Long = INITIAL_MS,
|
||||||
|
private val maxMs: Long = MAX_MS,
|
||||||
|
) {
|
||||||
|
private var currentMs = initialMs
|
||||||
|
|
||||||
|
/** Delay before the next attempt; advances the sequence for the one after. */
|
||||||
|
fun nextDelayMs(): Long {
|
||||||
|
val delay = currentMs
|
||||||
|
currentMs = (currentMs * 2).coerceAtMost(maxMs)
|
||||||
|
return delay
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Back to [initialMs] after a successful connection. */
|
||||||
|
fun reset() {
|
||||||
|
currentMs = initialMs
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val INITIAL_MS = 500L
|
||||||
|
const val MAX_MS = 10_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import pt.naps.maestro.data.CatalogEvent
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session catalog folded from `WS /api/events`. A [CatalogEvent.Snapshot]
|
||||||
|
* replaces the map outright; deltas mutate one entry.
|
||||||
|
*/
|
||||||
|
class CatalogStore {
|
||||||
|
private val _sessions = MutableStateFlow<Map<Uuid, Session>>(emptyMap())
|
||||||
|
val sessions: StateFlow<Map<Uuid, Session>> = _sessions.asStateFlow()
|
||||||
|
|
||||||
|
/** Applies one event. Returns the skip count for [CatalogEvent.Lagged] —
|
||||||
|
* the caller re-fetches a full snapshot over REST and calls [replace],
|
||||||
|
* mirroring how the web client reacts to a dropped broadcast frame. */
|
||||||
|
fun apply(event: CatalogEvent): Long? =
|
||||||
|
when (event) {
|
||||||
|
is CatalogEvent.Snapshot -> {
|
||||||
|
_sessions.value = event.sessions.associateBy { it.id }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
is CatalogEvent.SessionAdded -> {
|
||||||
|
_sessions.value = _sessions.value + (event.session.id to event.session)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
is CatalogEvent.SessionUpdated -> {
|
||||||
|
_sessions.value = _sessions.value + (event.session.id to event.session)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
is CatalogEvent.SessionRemoved -> {
|
||||||
|
_sessions.value = _sessions.value - event.id
|
||||||
|
null
|
||||||
|
}
|
||||||
|
is CatalogEvent.Lagged -> event.skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
fun replace(sessions: List<Session>) {
|
||||||
|
_sessions.value = sessions.associateBy { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
_sessions.value = emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies [onAvailable] whenever a usable network appears — including the
|
||||||
|
* wifi/cellular handoff, which Android reports as one network disappearing
|
||||||
|
* and another becoming available rather than a single "changed" event.
|
||||||
|
* [DaemonLink] treats every call as "worth reconnecting for" rather than
|
||||||
|
* inspecting which network it is.
|
||||||
|
*/
|
||||||
|
class ConnectivityObserver(
|
||||||
|
context: Context,
|
||||||
|
private val onAvailable: () -> Unit,
|
||||||
|
) {
|
||||||
|
private val connectivityManager =
|
||||||
|
context.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
|
||||||
|
private val callback = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) = onAvailable.invoke()
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
|
||||||
|
if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||||
|
networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
|
) {
|
||||||
|
onAvailable.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var registered = false
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
if (registered) return
|
||||||
|
val request = NetworkRequest.Builder()
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
.build()
|
||||||
|
connectivityManager.registerNetworkCallback(request, callback)
|
||||||
|
registered = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
if (!registered) return
|
||||||
|
runCatching { connectivityManager.unregisterNetworkCallback(callback) }
|
||||||
|
registered = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import pt.naps.maestro.data.BuildInfo
|
||||||
|
import pt.naps.maestro.data.DaemonInfo
|
||||||
|
|
||||||
|
/** What happened between two `/api/info` replies. */
|
||||||
|
enum class RestartKind { SAME, RESTARTED, UPGRADED }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two daemon replies. Mirrors `compareIdentity` in
|
||||||
|
* `web/src/components/daemon/DaemonLink.tsx` / `compare_identity` in
|
||||||
|
* `crates/maestro-tui/src/daemon_link.rs`.
|
||||||
|
*
|
||||||
|
* The commit decides first: a different one is new server code. `instance`
|
||||||
|
* catches everything else — a restart of the same build — and is the only
|
||||||
|
* signal for a daemon built outside a git checkout, where the commit is
|
||||||
|
* always empty and an empty-vs-empty pair must never read as an upgrade.
|
||||||
|
*/
|
||||||
|
fun compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
|
||||||
|
val a = before.build.commit
|
||||||
|
val b = after.build.commit
|
||||||
|
if (a.isNotEmpty() && b.isNotEmpty() && a != b) return RestartKind.UPGRADED
|
||||||
|
if (before.instance.isNotEmpty() && after.instance.isNotEmpty() && before.instance != after.instance) {
|
||||||
|
return RestartKind.RESTARTED
|
||||||
|
}
|
||||||
|
return RestartKind.SAME
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, `(dirty)` when the
|
||||||
|
* build carried uncommitted changes. Mirrors `formatRelease` / `format_release`. */
|
||||||
|
fun formatRelease(build: BuildInfo): String {
|
||||||
|
if (build.commit.isEmpty()) return "unknown release"
|
||||||
|
val parts = mutableListOf(build.commit)
|
||||||
|
if (build.commit_date.length >= 10) parts.add("· ${build.commit_date.take(10)}")
|
||||||
|
if (build.dirty) parts.add("(dirty)")
|
||||||
|
return parts.joinToString(" ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import pt.naps.maestro.api.EventsConnection
|
||||||
|
import pt.naps.maestro.api.MaestroClient
|
||||||
|
import pt.naps.maestro.api.MaestroWebSocket
|
||||||
|
import pt.naps.maestro.data.BuildInfo
|
||||||
|
import pt.naps.maestro.data.DaemonInfo
|
||||||
|
|
||||||
|
/** How long a dropped link stays [LinkState.RECONNECTING] before flipping to
|
||||||
|
* [LinkState.DOWN] — long enough to ride out a blip, short enough that a real
|
||||||
|
* outage shows up. Mirrors the grace window in `docs/RESTART.md`. */
|
||||||
|
private const val GRACE_MS = 1_200L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the connection to one daemon for the app's whole life: liveness,
|
||||||
|
* identity, and the session catalog. A Kotlin port of the state machine in
|
||||||
|
* `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs`
|
||||||
|
* — see `docs/RESTART.md` for the contract both mirror — with exponential
|
||||||
|
* [Backoff] standing in for their flat poll, since a phone's network changes
|
||||||
|
* are bigger and longer-lived than the LAN blips those clients ride out.
|
||||||
|
*
|
||||||
|
* Everything that can change the link — a probe landing, the events socket
|
||||||
|
* dropping, the app resuming, the network coming back, a new daemon URL —
|
||||||
|
* goes through one [signals] channel consumed by one coroutine, so state
|
||||||
|
* transitions never race each other.
|
||||||
|
*/
|
||||||
|
class DaemonLink(
|
||||||
|
context: Context,
|
||||||
|
private val prefs: DaemonPrefs,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
|
private val httpClient: OkHttpClient = OkHttpClient(),
|
||||||
|
) : DefaultLifecycleObserver {
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(LinkState.CONNECTING)
|
||||||
|
val state: StateFlow<LinkState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _build = MutableStateFlow<BuildInfo?>(null)
|
||||||
|
val build: StateFlow<BuildInfo?> = _build.asStateFlow()
|
||||||
|
|
||||||
|
private val _attempts = MutableStateFlow(0)
|
||||||
|
val attempts: StateFlow<Int> = _attempts.asStateFlow()
|
||||||
|
|
||||||
|
val catalog = CatalogStore()
|
||||||
|
|
||||||
|
private val backoff = Backoff()
|
||||||
|
private val signals = Channel<Signal>(Channel.UNLIMITED)
|
||||||
|
private val connectivity = ConnectivityObserver(appContext) { signals.trySend(Signal.NetworkAvailable) }
|
||||||
|
|
||||||
|
private var generation = 0
|
||||||
|
private var currentBaseUrl: String = prefs.baseUrl.value
|
||||||
|
private var client = MaestroClient(currentBaseUrl, httpClient)
|
||||||
|
private var socket = MaestroWebSocket(currentBaseUrl, httpClient)
|
||||||
|
private var identity: DaemonInfo? = null
|
||||||
|
|
||||||
|
private var eventsJob: Job? = null
|
||||||
|
private var eventsConnection: EventsConnection? = null
|
||||||
|
private var retryJob: Job? = null
|
||||||
|
private var graceJob: Job? = null
|
||||||
|
private var loopJob: Job? = null
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
private sealed class Signal {
|
||||||
|
object Start : Signal()
|
||||||
|
object Resume : Signal()
|
||||||
|
object NetworkAvailable : Signal()
|
||||||
|
object SocketDropped : Signal()
|
||||||
|
data class BaseUrlChanged(val url: String) : Signal()
|
||||||
|
data class ProbeResult(val generation: Int, val info: DaemonInfo?) : Signal()
|
||||||
|
data class Lagged(val skipped: Long) : Signal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts the link: subscribes to process lifecycle and connectivity, and
|
||||||
|
* kicks off the first probe. Call once. */
|
||||||
|
fun start() {
|
||||||
|
if (started) return
|
||||||
|
started = true
|
||||||
|
loopJob = scope.launch { runLoop() }
|
||||||
|
connectivity.start()
|
||||||
|
scope.launch(Dispatchers.Main.immediate) {
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.addObserver(this@DaemonLink)
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
prefs.baseUrl.collect { url ->
|
||||||
|
if (url != currentBaseUrl) signals.trySend(Signal.BaseUrlChanged(url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
signals.trySend(Signal.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tears everything down. Only needed by tests and by an owner that is
|
||||||
|
* itself going away for good (this link is meant to outlive an Activity). */
|
||||||
|
fun close() {
|
||||||
|
connectivity.stop()
|
||||||
|
cancelRetry()
|
||||||
|
cancelGrace()
|
||||||
|
closeEvents()
|
||||||
|
loopJob?.cancel()
|
||||||
|
signals.close()
|
||||||
|
scope.launch(Dispatchers.Main.immediate) {
|
||||||
|
runCatching { ProcessLifecycleOwner.get().lifecycle.removeObserver(this@DaemonLink) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
|
signals.trySend(Signal.Resume)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runLoop() {
|
||||||
|
for (signal in signals) {
|
||||||
|
when (signal) {
|
||||||
|
is Signal.Start -> probeNow()
|
||||||
|
is Signal.Resume -> {
|
||||||
|
probeNow()
|
||||||
|
ensureEventsConnected()
|
||||||
|
}
|
||||||
|
is Signal.NetworkAvailable -> {
|
||||||
|
cancelRetry()
|
||||||
|
probeNow()
|
||||||
|
}
|
||||||
|
is Signal.SocketDropped -> handleSocketDropped()
|
||||||
|
is Signal.BaseUrlChanged -> handleBaseUrlChanged(signal.url)
|
||||||
|
is Signal.ProbeResult -> {
|
||||||
|
if (signal.generation == generation) handleProbeResult(signal.info)
|
||||||
|
}
|
||||||
|
is Signal.Lagged -> handleLagged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun probeNow() {
|
||||||
|
val gen = generation
|
||||||
|
val currentClient = client
|
||||||
|
scope.launch {
|
||||||
|
val info = runCatching { currentClient.getDaemonInfo() }.getOrNull()
|
||||||
|
signals.trySend(Signal.ProbeResult(gen, info))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleProbeResult(info: DaemonInfo?) {
|
||||||
|
if (info == null) {
|
||||||
|
onProbeFailed()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val before = identity
|
||||||
|
identity = info
|
||||||
|
_build.value = info.build
|
||||||
|
if (before != null && compareIdentity(before, info) == RestartKind.UPGRADED) {
|
||||||
|
_state.value = LinkState.UPDATING
|
||||||
|
fullReconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
backoff.reset()
|
||||||
|
cancelRetry()
|
||||||
|
cancelGrace()
|
||||||
|
_attempts.value = 0
|
||||||
|
_state.value = LinkState.ONLINE
|
||||||
|
ensureEventsConnected()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onProbeFailed() {
|
||||||
|
_attempts.value += 1
|
||||||
|
if (_state.value != LinkState.DOWN && _state.value != LinkState.UPDATING) {
|
||||||
|
_state.value = LinkState.RECONNECTING
|
||||||
|
armGrace()
|
||||||
|
}
|
||||||
|
scheduleRetry()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun armGrace() {
|
||||||
|
if (graceJob?.isActive == true) return
|
||||||
|
graceJob = scope.launch {
|
||||||
|
delay(GRACE_MS)
|
||||||
|
if (_state.value == LinkState.RECONNECTING) _state.value = LinkState.DOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelGrace() {
|
||||||
|
graceJob?.cancel()
|
||||||
|
graceJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleRetry() {
|
||||||
|
if (retryJob?.isActive == true) return
|
||||||
|
val delayMs = backoff.nextDelayMs()
|
||||||
|
retryJob = scope.launch {
|
||||||
|
delay(delayMs)
|
||||||
|
retryJob = null
|
||||||
|
probeNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelRetry() {
|
||||||
|
retryJob?.cancel()
|
||||||
|
retryJob = null
|
||||||
|
backoff.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A build-stamp change: drop the events socket and the catalog it fed,
|
||||||
|
* then probe again shortly. The re-probe sees the same identity it just
|
||||||
|
* recorded, so it resolves straight to [LinkState.ONLINE] and reopens
|
||||||
|
* the socket — this *is* the full reconnect. */
|
||||||
|
private fun fullReconnect() {
|
||||||
|
closeEvents()
|
||||||
|
catalog.clear()
|
||||||
|
backoff.reset()
|
||||||
|
scope.launch {
|
||||||
|
delay(200)
|
||||||
|
probeNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSocketDropped() {
|
||||||
|
if (_state.value == LinkState.DOWN || _state.value == LinkState.UPDATING) return
|
||||||
|
_state.value = LinkState.RECONNECTING
|
||||||
|
armGrace()
|
||||||
|
probeNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleBaseUrlChanged(url: String) {
|
||||||
|
currentBaseUrl = url
|
||||||
|
generation += 1
|
||||||
|
client = MaestroClient(url, httpClient)
|
||||||
|
socket = MaestroWebSocket(url, httpClient)
|
||||||
|
identity = null
|
||||||
|
_build.value = null
|
||||||
|
_attempts.value = 0
|
||||||
|
backoff.reset()
|
||||||
|
cancelRetry()
|
||||||
|
cancelGrace()
|
||||||
|
closeEvents()
|
||||||
|
catalog.clear()
|
||||||
|
_state.value = LinkState.CONNECTING
|
||||||
|
probeNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The broadcast channel dropped frames: re-fetch the full list over REST
|
||||||
|
* rather than trusting the map to still be complete. */
|
||||||
|
private fun handleLagged() {
|
||||||
|
val currentClient = client
|
||||||
|
scope.launch {
|
||||||
|
runCatching { currentClient.listSessions() }.onSuccess { catalog.replace(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureEventsConnected() {
|
||||||
|
if (eventsJob?.isActive == true) return
|
||||||
|
val connection = socket.connectEvents()
|
||||||
|
eventsConnection = connection
|
||||||
|
eventsJob = scope.launch {
|
||||||
|
try {
|
||||||
|
connection.events.collect { event ->
|
||||||
|
val skipped = catalog.apply(event)
|
||||||
|
if (skipped != null) signals.trySend(Signal.Lagged(skipped))
|
||||||
|
}
|
||||||
|
signals.trySend(Signal.SocketDropped)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
signals.trySend(Signal.SocketDropped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeEvents() {
|
||||||
|
eventsJob?.cancel()
|
||||||
|
eventsJob = null
|
||||||
|
eventsConnection?.close()
|
||||||
|
eventsConnection = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.edit
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the daemon base URL lives between launches. A settings screen and
|
||||||
|
* the [EXTRA_DAEMON_URL] intent extra both write through here; [DaemonLink]
|
||||||
|
* reads [baseUrl] and reconnects on change.
|
||||||
|
*/
|
||||||
|
class DaemonPrefs(context: Context) {
|
||||||
|
private val prefs = context.applicationContext.getSharedPreferences(FILE, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private val _baseUrl =
|
||||||
|
MutableStateFlow(prefs.getString(KEY_BASE_URL, DEFAULT_BASE_URL) ?: DEFAULT_BASE_URL)
|
||||||
|
val baseUrl: StateFlow<String> = _baseUrl.asStateFlow()
|
||||||
|
|
||||||
|
fun setBaseUrl(url: String) {
|
||||||
|
val trimmed = url.trim()
|
||||||
|
if (trimmed.isEmpty() || trimmed == _baseUrl.value) return
|
||||||
|
prefs.edit { putString(KEY_BASE_URL, trimmed) }
|
||||||
|
_baseUrl.value = trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val FILE = "daemon_prefs"
|
||||||
|
private const val KEY_BASE_URL = "base_url"
|
||||||
|
|
||||||
|
/** Shown until the user points the app at their own daemon. */
|
||||||
|
const val DEFAULT_BASE_URL = "http://192.168.1.x:7070"
|
||||||
|
|
||||||
|
/** Intent extra a launcher may set to point this launch at a specific
|
||||||
|
* daemon without going through settings. Persisted on read, so it
|
||||||
|
* sticks after the launch that carried it. */
|
||||||
|
const val EXTRA_DAEMON_URL = "pt.naps.maestro.EXTRA_DAEMON_URL"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection lifecycle to the daemon. Mirrors the states in
|
||||||
|
* `web/src/components/daemon/DaemonLink.tsx` / `crates/maestro-tui/src/daemon_link.rs`
|
||||||
|
* (see `docs/RESTART.md`), plus [UPDATING] for a build-stamp change.
|
||||||
|
*
|
||||||
|
* - [CONNECTING] — first probe hasn't landed yet.
|
||||||
|
* - [ONLINE] — the daemon answered and identity is settled.
|
||||||
|
* - [RECONNECTING] — the link dropped, still inside the grace window.
|
||||||
|
* - [DOWN] — unreachable past the grace window; the restart overlay blocks.
|
||||||
|
* - [UPDATING] — the daemon answered on a different commit; reconnecting fresh.
|
||||||
|
*/
|
||||||
|
enum class LinkState { CONNECTING, ONLINE, RECONNECTING, DOWN, UPDATING }
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session activity, orthogonal to [SessionStatus]. Mirrors
|
||||||
|
* `maestro_protocol::ActivityState`.
|
||||||
|
*
|
||||||
|
* Deserializes leniently: an unknown variant from a newer daemon decodes as
|
||||||
|
* [Idle] instead of failing the whole [Session].
|
||||||
|
*/
|
||||||
|
@Serializable(with = ActivityStateSerializer::class)
|
||||||
|
enum class ActivityState {
|
||||||
|
Active,
|
||||||
|
Idle,
|
||||||
|
AwaitingInput,
|
||||||
|
Error,
|
||||||
|
Starting,
|
||||||
|
}
|
||||||
|
|
||||||
|
object ActivityStateSerializer : KSerializer<ActivityState> {
|
||||||
|
override val descriptor: SerialDescriptor =
|
||||||
|
PrimitiveSerialDescriptor("ActivityState", PrimitiveKind.STRING)
|
||||||
|
|
||||||
|
override fun serialize(encoder: Encoder, value: ActivityState) {
|
||||||
|
encoder.encodeString(value.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): ActivityState =
|
||||||
|
when (decoder.decodeString()) {
|
||||||
|
"Active" -> ActivityState.Active
|
||||||
|
"AwaitingInput" -> ActivityState.AwaitingInput
|
||||||
|
"Error" -> ActivityState.Error
|
||||||
|
"Starting" -> ActivityState.Starting
|
||||||
|
else -> ActivityState.Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** Structured failure body inside an [ApiError] envelope. Mirrors
|
||||||
|
* `maestro_protocol::ErrorInfo`. */
|
||||||
|
@Serializable
|
||||||
|
data class ErrorInfo(
|
||||||
|
val code: String,
|
||||||
|
val message: String,
|
||||||
|
/** Set only by the kill guard: what blocked an unforced
|
||||||
|
* `DELETE /api/sessions/:id`. */
|
||||||
|
val unsaved_work: InTheWay? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The envelope every `/api` failure is wrapped in: `{"error": {...}}`.
|
||||||
|
* Mirrors `maestro_protocol::ApiError`. */
|
||||||
|
@Serializable
|
||||||
|
data class ApiError(val error: ErrorInfo)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** One local branch of a repository, from `GET /api/branches`. A client
|
||||||
|
* disables the entry when [is_head] is set or [worktree] is present — git
|
||||||
|
* refuses to check the same branch out in two worktrees at once. Mirrors
|
||||||
|
* `maestro_protocol::BranchInfo`. */
|
||||||
|
@Serializable
|
||||||
|
data class BranchInfo(
|
||||||
|
/** Short branch name (`main`, `feature/foo`). */
|
||||||
|
val name: String,
|
||||||
|
/** Whether this branch is the main checkout's current HEAD. */
|
||||||
|
val is_head: Boolean,
|
||||||
|
/** Toplevel path of the linked worktree holding this branch, if any. */
|
||||||
|
val worktree: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Messages the daemon broadcasts to clients over `WS /api/events`. Mirrors
|
||||||
|
* `maestro_protocol::CatalogEvent`; tagged by a `type` field.
|
||||||
|
*
|
||||||
|
* A subscriber receives a single [Snapshot] immediately on connect, then a
|
||||||
|
* stream of deltas as sessions are created, updated, and killed. Skipped
|
||||||
|
* events (broadcast channel lag) surface as [Lagged].
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
sealed class CatalogEvent {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Snapshot")
|
||||||
|
data class Snapshot(val sessions: List<Session>) : CatalogEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("SessionAdded")
|
||||||
|
data class SessionAdded(val session: Session) : CatalogEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("SessionRemoved")
|
||||||
|
data class SessionRemoved(val id: Uuid) : CatalogEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("SessionUpdated")
|
||||||
|
data class SessionUpdated(val session: Session) : CatalogEvent()
|
||||||
|
|
||||||
|
/** Server-issued notification that the broadcast queue dropped frames.
|
||||||
|
* Clients should re-fetch a full snapshot. */
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Lagged")
|
||||||
|
data class Lagged(val skipped: Long) : CatalogEvent()
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** Text-frame control messages on a `WS /api/attach/:id` connection. PTY
|
||||||
|
* bytes travel in binary frames and are not modeled here. Mirrors
|
||||||
|
* `maestro_protocol::ClientControl`; tagged by a `type` field. */
|
||||||
|
@Serializable
|
||||||
|
sealed class ClientControl {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Resize")
|
||||||
|
data class Resize(val cols: Int, val rows: Int) : ClientControl()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Ping")
|
||||||
|
object Ping : ClientControl()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Pong")
|
||||||
|
object Pong : ClientControl()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("Error")
|
||||||
|
data class Error(
|
||||||
|
val code: String,
|
||||||
|
val message: String,
|
||||||
|
val unsaved_work: InTheWay? = null,
|
||||||
|
) : ClientControl()
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** `GET /api/info` — the daemon's identity plus its spawn presets. Mirrors
|
||||||
|
* `maestro_protocol::DaemonInfo`. */
|
||||||
|
@Serializable
|
||||||
|
data class DaemonInfo(
|
||||||
|
val hostname: String = "",
|
||||||
|
/** The protocol version the daemon implements. */
|
||||||
|
val version: String = "",
|
||||||
|
/** Home dir of the user the daemon runs as; empty for older daemons. */
|
||||||
|
val home_dir: String = "",
|
||||||
|
val presets: List<SpawnPreset> = emptyList(),
|
||||||
|
val build: BuildInfo = BuildInfo(),
|
||||||
|
/** Identity of the daemon *process*, regenerated on every start — a
|
||||||
|
* changed value across a reconnect means the daemon restarted. */
|
||||||
|
val instance: String = "",
|
||||||
|
/** Canonical leader key the operator configured (`[tui].leader`), so a
|
||||||
|
* client binds the same chord the TUI does. Empty means "use your
|
||||||
|
* default". */
|
||||||
|
val leader: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A spawn-form command preset advertised by the daemon. */
|
||||||
|
@Serializable
|
||||||
|
data class SpawnPreset(
|
||||||
|
val id: String,
|
||||||
|
val label: String,
|
||||||
|
val command: String,
|
||||||
|
val auto_approve: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Build provenance of the running daemon. Every field is empty when the
|
||||||
|
* binary was built outside a git checkout. */
|
||||||
|
@Serializable
|
||||||
|
data class BuildInfo(
|
||||||
|
val commit: String = "",
|
||||||
|
val commit_date: String = "",
|
||||||
|
val dirty: Boolean = false,
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** `GET /api/dirs?path=` — one directory's immediate subdirectories. Mirrors
|
||||||
|
* `maestro_protocol::DirListing`. */
|
||||||
|
@Serializable
|
||||||
|
data class DirListing(
|
||||||
|
/** Resolved absolute directory that was listed (after ~/relative
|
||||||
|
* expansion). */
|
||||||
|
val path: String,
|
||||||
|
/** Immediate subdirectory names, sorted case-insensitively. */
|
||||||
|
val entries: List<String> = emptyList(),
|
||||||
|
/** True when the listing was capped server-side. */
|
||||||
|
val truncated: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One deduplicated recent working dir for the spawn form's recents picker.
|
||||||
|
* Mirrors `maestro_protocol::RecentDir`. */
|
||||||
|
@Serializable
|
||||||
|
data class RecentDir(
|
||||||
|
val cwd: String,
|
||||||
|
val last_command: String,
|
||||||
|
val last_used: IsoTimestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /api/recent-dirs` — the daemon's recent working dirs, newest-first
|
||||||
|
* and deduped by `cwd`. */
|
||||||
|
@Serializable
|
||||||
|
data class RecentDirs(
|
||||||
|
val dirs: List<RecentDir> = emptyList(),
|
||||||
|
val truncated: Boolean = false,
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** `GET /api/gitroot?path=` — the enclosing repo toplevel of a path, or
|
||||||
|
* `null` when the path isn't inside a repo. Mirrors
|
||||||
|
* `maestro_protocol::GitRoot`. */
|
||||||
|
@Serializable
|
||||||
|
data class GitRoot(val root: String? = null)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
/** A session id. Mirrors `uuid::Uuid` on the wire — a plain UUID string. */
|
||||||
|
typealias Uuid = String
|
||||||
|
|
||||||
|
/** An RFC 3339 timestamp, as the daemon serializes `chrono::DateTime<Utc>`. */
|
||||||
|
typealias IsoTimestamp = String
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** What currently blocks an unforced kill of a session's worktree,
|
||||||
|
* recomputed by the daemon on every read. Mirrors
|
||||||
|
* `maestro_protocol::InTheWay`. */
|
||||||
|
@Serializable
|
||||||
|
data class InTheWay(
|
||||||
|
val uncommitted_files: Int = 0,
|
||||||
|
val untracked_files: Int = 0,
|
||||||
|
val unpushed_commits: Int = 0,
|
||||||
|
/** Lines added across the tracked changes, staged and unstaged together. */
|
||||||
|
val insertions: Int = 0,
|
||||||
|
/** Lines removed across the same tracked changes. */
|
||||||
|
val deletions: Int = 0,
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** `GET /api/names?count=N` — a batch of fresh session handles. Mirrors
|
||||||
|
* `maestro_protocol::NameBatch`. */
|
||||||
|
@Serializable
|
||||||
|
data class NameBatch(val names: List<String>)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||||
|
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||||
|
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||||
|
import kotlinx.serialization.encoding.Decoder
|
||||||
|
import kotlinx.serialization.encoding.Encoder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a session's stored name (`metadata["name"]`) came from. Mirrors
|
||||||
|
* `maestro_protocol::NameSource`.
|
||||||
|
*
|
||||||
|
* Deserializes leniently: an unknown spelling decodes as [User], the settled
|
||||||
|
* variant — a name wrongly painted as settled is only a missed dimming; one
|
||||||
|
* wrongly painted as provisional invites overwriting a name a person chose.
|
||||||
|
*/
|
||||||
|
@Serializable(with = NameSourceSerializer::class)
|
||||||
|
enum class NameSource {
|
||||||
|
User,
|
||||||
|
Generated,
|
||||||
|
Haiku,
|
||||||
|
}
|
||||||
|
|
||||||
|
object NameSourceSerializer : KSerializer<NameSource> {
|
||||||
|
override val descriptor: SerialDescriptor =
|
||||||
|
PrimitiveSerialDescriptor("NameSource", PrimitiveKind.STRING)
|
||||||
|
|
||||||
|
override fun serialize(encoder: Encoder, value: NameSource) {
|
||||||
|
encoder.encodeString(value.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deserialize(decoder: Decoder): NameSource =
|
||||||
|
when (decoder.decodeString()) {
|
||||||
|
"Generated" -> NameSource.Generated
|
||||||
|
"Haiku" -> NameSource.Haiku
|
||||||
|
else -> NameSource.User
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/** One discovered git repository (or linked worktree) for `GET
|
||||||
|
* /api/projects`. Mirrors `maestro_protocol::Project`. */
|
||||||
|
@Serializable
|
||||||
|
data class Project(
|
||||||
|
/** Absolute path of the repository/worktree toplevel. */
|
||||||
|
val path: String,
|
||||||
|
/** Basename of [path]. */
|
||||||
|
val name: String,
|
||||||
|
val is_worktree: Boolean = false,
|
||||||
|
/** The main checkout this worktree belongs to; `null` on plain repos. */
|
||||||
|
val base_repo: String? = null,
|
||||||
|
/** Later of the repo's git HEAD mtime and the newest spawn-history use of
|
||||||
|
* any directory inside it; `null` when neither is known. */
|
||||||
|
val last_activity: IsoTimestamp? = null,
|
||||||
|
/** Command last spawned in this project, from spawn history. */
|
||||||
|
val last_command: String? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
/** Default for [Session.last_activity_at] / [Session.activity_since] when an
|
||||||
|
* older payload omits them — mirrors the daemon's Unix-epoch default. */
|
||||||
|
const val EPOCH: IsoTimestamp = "1970-01-01T00:00:00Z"
|
||||||
|
|
||||||
|
/** A live or recently-dead tmux session managed by the daemon. Mirrors
|
||||||
|
* `maestro_protocol::Session`. */
|
||||||
|
@Serializable
|
||||||
|
data class Session(
|
||||||
|
val id: Uuid,
|
||||||
|
val cwd: String,
|
||||||
|
val command: String,
|
||||||
|
val env: Map<String, String> = emptyMap(),
|
||||||
|
val metadata: Map<String, JsonElement> = emptyMap(),
|
||||||
|
/** The worktree this session owns, when it was spawned with one. */
|
||||||
|
val worktree: SessionWorktree? = null,
|
||||||
|
val status: SessionStatus,
|
||||||
|
val created_at: IsoTimestamp,
|
||||||
|
/** Current foreground command in the pane (e.g. `claude`, `bash`), as the
|
||||||
|
* daemon's activity watcher observed it. `null` until observed. */
|
||||||
|
val foreground: String? = null,
|
||||||
|
/** Inferred activity dimension, orthogonal to [status]. */
|
||||||
|
val activity: ActivityState = ActivityState.Idle,
|
||||||
|
/** Short human string an agent reported alongside its activity — e.g. why
|
||||||
|
* it is in [ActivityState.Error]. `null` unless an agent reported one. */
|
||||||
|
val activity_detail: String? = null,
|
||||||
|
val last_activity_at: IsoTimestamp = EPOCH,
|
||||||
|
val activity_since: IsoTimestamp = EPOCH,
|
||||||
|
/** The pane's *current* working directory, distinct from [cwd] (the spawn
|
||||||
|
* dir). `null` until first observed. */
|
||||||
|
val current_cwd: String? = null,
|
||||||
|
/** What currently blocks an unforced kill of this session, as the
|
||||||
|
* daemon's watcher last probed it. `null` while clear or not yet probed. */
|
||||||
|
val blocked_work: InTheWay? = null,
|
||||||
|
/** When this session last became the pane a client was showing. `null`
|
||||||
|
* until the session has been focused at least once. */
|
||||||
|
val last_focused_at: IsoTimestamp? = null,
|
||||||
|
/** Terminal (PTY bytes) or structured (ACP rows). Fixed at spawn and
|
||||||
|
* immutable for the session's life. */
|
||||||
|
val view: SessionView = SessionView.Terminal,
|
||||||
|
/** The ACP session id the selected provider adapter returned from
|
||||||
|
* `session/new`. Only a [SessionView.Structured] session whose handshake
|
||||||
|
* succeeded has one. */
|
||||||
|
val acp_session_id: String? = null,
|
||||||
|
/** When `true`, the daemon auto-approves every ACP permission request for
|
||||||
|
* this session instead of surfacing them to the client. */
|
||||||
|
val auto_approve: Boolean = false,
|
||||||
|
/** Prompts queued on the daemon while a turn was in flight or the adapter
|
||||||
|
* was still booting. */
|
||||||
|
val queued_prompts: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
enum class SessionStatus { Running, Dead }
|
||||||
|
|
||||||
|
/** A session's process shape: mirrors `maestro_protocol::SessionView`.
|
||||||
|
* `Terminal` is the default. */
|
||||||
|
@Serializable
|
||||||
|
enum class SessionView { Terminal, Structured }
|
||||||
|
|
||||||
|
/** A session's daemon-created worktree. `base_repo` is the repository's main
|
||||||
|
* checkout, which is what the session tree groups by. */
|
||||||
|
@Serializable
|
||||||
|
data class SessionWorktree(
|
||||||
|
val path: String,
|
||||||
|
val base_repo: String,
|
||||||
|
)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
/** `POST /api/sessions` body. Mirrors
|
||||||
|
* `maestro_protocol::CreateSessionRequest`. */
|
||||||
|
@Serializable
|
||||||
|
data class CreateSessionRequest(
|
||||||
|
val cwd: String,
|
||||||
|
val command: String,
|
||||||
|
val env: Map<String, String> = emptyMap(),
|
||||||
|
val metadata: Map<String, JsonElement> = emptyMap(),
|
||||||
|
val worktree: WorktreeSpec? = null,
|
||||||
|
/** The process shape to spawn. Defaults to a `Terminal` session. */
|
||||||
|
val view: SessionView = SessionView.Terminal,
|
||||||
|
/** When `true`, the daemon auto-approves every ACP permission request for
|
||||||
|
* this structured session. */
|
||||||
|
val auto_approve: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Optional worktree request attached to a [CreateSessionRequest]. Mirrors
|
||||||
|
* `maestro_protocol::WorktreeSpec`. */
|
||||||
|
@Serializable
|
||||||
|
data class WorktreeSpec(
|
||||||
|
val base_repo: String,
|
||||||
|
val branch: String,
|
||||||
|
/** When `false`, [branch] already exists and is checked out as-is.
|
||||||
|
* Defaults to `true` (create a new branch). */
|
||||||
|
val create_branch: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `PATCH /api/sessions/:id` body: rename a live session. An empty [name]
|
||||||
|
* clears the custom one, so the card falls back to the command. Mirrors
|
||||||
|
* `maestro_protocol::RenameSessionRequest`. */
|
||||||
|
@Serializable
|
||||||
|
data class RenameSessionRequest(val name: String)
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/focus` body: a client reports whether its pane is
|
||||||
|
* the one currently showing this session. Mirrors
|
||||||
|
* `maestro_protocol::SetFocusRequest`. */
|
||||||
|
@Serializable
|
||||||
|
data class SetFocusRequest(val focused: Boolean)
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
/** The semantic shape of a [TranscriptRow]. Mirrors
|
||||||
|
* `maestro_protocol::TranscriptRowKind`, snake_case on the wire. */
|
||||||
|
@Serializable
|
||||||
|
enum class TranscriptRowKind {
|
||||||
|
@SerialName("user") User,
|
||||||
|
@SerialName("agent_text") AgentText,
|
||||||
|
@SerialName("agent_thought") AgentThought,
|
||||||
|
@SerialName("tool_call") ToolCall,
|
||||||
|
@SerialName("tool_diff") ToolDiff,
|
||||||
|
@SerialName("approval") Approval,
|
||||||
|
@SerialName("plan") Plan,
|
||||||
|
@SerialName("notice") Notice,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One client-neutral item in a structured session transcript. [data]
|
||||||
|
* preserves the ACP payload verbatim without committing to an ACP
|
||||||
|
* dependency. Mirrors `maestro_protocol::TranscriptRow`. */
|
||||||
|
@Serializable
|
||||||
|
data class TranscriptRow(
|
||||||
|
/** Stable within one session, so later deltas can address this row. */
|
||||||
|
val id: String,
|
||||||
|
val kind: TranscriptRowKind,
|
||||||
|
val data: JsonElement,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The complete transcript at a durable event-log position — a structured
|
||||||
|
* session's first `WS /api/structured/:id` message. Mirrors
|
||||||
|
* `maestro_protocol::TranscriptSnapshot`. */
|
||||||
|
@Serializable
|
||||||
|
data class TranscriptSnapshot(
|
||||||
|
val cursor: Long,
|
||||||
|
val rows: List<TranscriptRow> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A change to one transcript row, addressed by [id]. Mirrors
|
||||||
|
* `maestro_protocol::TranscriptDelta`; tagged by a snake_case `type` field. */
|
||||||
|
@Serializable
|
||||||
|
sealed class TranscriptDelta {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("append")
|
||||||
|
data class Append(val row: TranscriptRow) : TranscriptDelta()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("patch")
|
||||||
|
data class Patch(val row: TranscriptRow) : TranscriptDelta()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("remove")
|
||||||
|
data class Remove(val id: String) : TranscriptDelta()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-turn indicators derived from the ACP stream — read verbatim, never
|
||||||
|
* re-derived client-side. Mirrors `maestro_protocol::TurnState`. */
|
||||||
|
@Serializable
|
||||||
|
data class TurnState(
|
||||||
|
val responding: Boolean = false,
|
||||||
|
val thinking: Boolean = false,
|
||||||
|
val active_tool_calls: List<String> = emptyList(),
|
||||||
|
/** Wall-clock start of the active prompt, so a reconnecting client keeps
|
||||||
|
* one continuous elapsed-time display instead of restarting at zero. */
|
||||||
|
val started_at: IsoTimestamp? = null,
|
||||||
|
val started_context_tokens: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A whole-session view derived from ACP notifications. Mirrors
|
||||||
|
* `maestro_protocol::ReducedState`.
|
||||||
|
*
|
||||||
|
* [pending_approval], [plan], [usage], [context] and [config_options] carry
|
||||||
|
* opaque ACP JSON — their shape is ACP's, not covered by this crate's own
|
||||||
|
* compatibility guarantee, so they are kept as [JsonElement] rather than
|
||||||
|
* modeled strongly.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ReducedState(
|
||||||
|
val turn: TurnState = TurnState(),
|
||||||
|
val pending_approval: JsonElement? = null,
|
||||||
|
val plan: JsonElement? = null,
|
||||||
|
val usage: JsonElement? = null,
|
||||||
|
val context: JsonElement? = null,
|
||||||
|
val model: String? = null,
|
||||||
|
val mode: String? = null,
|
||||||
|
val config_options: JsonElement? = null,
|
||||||
|
/** Prompts queued on the daemon while a turn is in flight. */
|
||||||
|
val queued_prompts: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Server messages on `WS /api/structured/:id`. Mirrors
|
||||||
|
* `maestro_protocol::StructuredEvent`; tagged by a snake_case `type` field. */
|
||||||
|
@Serializable
|
||||||
|
sealed class StructuredEvent {
|
||||||
|
@Serializable
|
||||||
|
@SerialName("transcript_snapshot")
|
||||||
|
data class TranscriptSnapshotEvent(val snapshot: TranscriptSnapshot) : StructuredEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("reduced_state")
|
||||||
|
data class ReducedStateEvent(val state: ReducedState) : StructuredEvent()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("projection_update")
|
||||||
|
data class ProjectionUpdate(
|
||||||
|
val transcript: TranscriptDelta? = null,
|
||||||
|
val state: ReducedState,
|
||||||
|
) : StructuredEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/prompt` body. */
|
||||||
|
@Serializable
|
||||||
|
data class StructuredPromptRequest(val prompt: String)
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/structured/config` body: change one option
|
||||||
|
* advertised by a live ACP session. */
|
||||||
|
@Serializable
|
||||||
|
data class StructuredConfigRequest(val config_id: String, val value: JsonElement)
|
||||||
|
|
||||||
|
/** Operator decision for a pending ACP permission request
|
||||||
|
* (`POST /api/sessions/:id/structured/approvals/:nonce` body) — a bare JSON
|
||||||
|
* string on the wire, not `{decision: ...}`. Mirrors
|
||||||
|
* `maestro_protocol::StructuredApprovalDecision`. */
|
||||||
|
@Serializable
|
||||||
|
enum class StructuredApprovalDecision {
|
||||||
|
@SerialName("allow_once") AllowOnce,
|
||||||
|
@SerialName("allow_for_session") AllowForSession,
|
||||||
|
@SerialName("deny") Deny,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Response of `POST /api/sessions/:id/structured/attachments`. */
|
||||||
|
@Serializable
|
||||||
|
data class StructuredAttachmentResponse(val path: String)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package pt.naps.maestro.ui.sidebar
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.InfiniteTransition
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import pt.naps.maestro.data.ActivityState
|
||||||
|
|
||||||
|
/** docs/SIDEBAR.md: the rail's width in the web client. Android mirrors it in
|
||||||
|
* density-independent pixels rather than raw pixels. */
|
||||||
|
val ACTIVITY_RAIL_WIDTH = 7.dp
|
||||||
|
|
||||||
|
private val RAIL_ACTIVE = Color(0xFF3DDC84)
|
||||||
|
private val RAIL_IDLE = Color(0xFF9AA0A6)
|
||||||
|
private val RAIL_AWAITING = Color(0xFFF5A623)
|
||||||
|
private val RAIL_ERROR = Color(0xFFE5484D)
|
||||||
|
|
||||||
|
/** Colour per activity state (docs/SIDEBAR.md's colour table): `Starting`
|
||||||
|
* shares `Idle`'s colour, and an unrecognised state degrades to `Idle` so
|
||||||
|
* rendering never fails (Precedence). */
|
||||||
|
fun activityColor(activity: ActivityState): Color =
|
||||||
|
when (activity) {
|
||||||
|
ActivityState.Active -> RAIL_ACTIVE
|
||||||
|
ActivityState.AwaitingInput -> RAIL_AWAITING
|
||||||
|
ActivityState.Error -> RAIL_ERROR
|
||||||
|
ActivityState.Idle, ActivityState.Starting -> RAIL_IDLE
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The accessible name the rail carries, since colour is never the only
|
||||||
|
* signal (docs/SIDEBAR.md). */
|
||||||
|
fun activityStateWord(activity: ActivityState): String =
|
||||||
|
when (activity) {
|
||||||
|
ActivityState.Active -> "Active"
|
||||||
|
ActivityState.Idle -> "Idle"
|
||||||
|
ActivityState.Starting -> "Starting"
|
||||||
|
ActivityState.AwaitingInput -> "Awaiting input"
|
||||||
|
ActivityState.Error -> "Error"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A held bar that pulses its alpha between 1 and [minAlpha] every
|
||||||
|
* [periodMs] — the blink/flash motion `AwaitingInput`/`Error` earn. */
|
||||||
|
@Composable
|
||||||
|
private fun InfiniteTransition.pulsingAlpha(minAlpha: Float, periodMs: Int): Float {
|
||||||
|
val alpha by animateFloat(
|
||||||
|
initialValue = 1f,
|
||||||
|
targetValue = minAlpha,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(durationMillis = periodMs, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse,
|
||||||
|
),
|
||||||
|
label = "activity-rail-alpha",
|
||||||
|
)
|
||||||
|
return alpha
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The card's rail: a coloured bar in the gutter, full card height. It paints
|
||||||
|
* for every card, `Idle` included, and carries motion only for states that
|
||||||
|
* need attention — a hard blink for `AwaitingInput`, a fast flash for
|
||||||
|
* `Error`. `Active` and `Idle` stay solid; colour alone is their signal. */
|
||||||
|
@Composable
|
||||||
|
fun ActivityRail(activity: ActivityState, modifier: Modifier = Modifier) {
|
||||||
|
val color by animateColorAsState(targetValue = activityColor(activity), label = "activity-rail-color")
|
||||||
|
val infinite = rememberInfiniteTransition(label = "activity-rail-motion")
|
||||||
|
val alpha = when (activity) {
|
||||||
|
ActivityState.AwaitingInput -> infinite.pulsingAlpha(minAlpha = 0.35f, periodMs = 600)
|
||||||
|
ActivityState.Error -> infinite.pulsingAlpha(minAlpha = 0.25f, periodMs = 220)
|
||||||
|
else -> 1f
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(ACTIVITY_RAIL_WIDTH)
|
||||||
|
.semantics { contentDescription = activityStateWord(activity) }
|
||||||
|
.background(color.copy(alpha = alpha)),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package pt.naps.maestro.ui.sidebar
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/** docs/SIDEBAR.md: a collapsible directory in the sidebar tree. An expanded
|
||||||
|
* folder's cards are one row away, so it carries no highlight of its own —
|
||||||
|
* only a collapsed header is ever a keyboard-walk stop, but that distinction
|
||||||
|
* belongs to the caller driving the walk, not this composable. */
|
||||||
|
@Composable
|
||||||
|
fun FolderNode(
|
||||||
|
label: String,
|
||||||
|
collapsed: Boolean,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
depthPadding: Int = 0,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onToggle)
|
||||||
|
.padding(start = depthPadding.dp, top = 6.dp, bottom = 6.dp, end = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (collapsed) "▸" else "▾",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AnimatedVisibility(visible = !collapsed) {
|
||||||
|
Column { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
package pt.naps.maestro.ui.sidebar
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import pt.naps.maestro.data.InTheWay
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SessionStatus
|
||||||
|
|
||||||
|
/** docs/SIDEBAR.md: a card is always two lines behind an activity rail —
|
||||||
|
* line 1 the name, line 2 the adaptive meta line. Long-press opens a context
|
||||||
|
* menu (Rename / Kill / Spawn similar); the daemon confirms rename and kill
|
||||||
|
* only through the callbacks the caller supplies. */
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun SessionCard(
|
||||||
|
session: Session,
|
||||||
|
nowMs: Long,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
depthPadding: Int = 0,
|
||||||
|
onOpen: (Session) -> Unit = {},
|
||||||
|
onRename: (Session, String) -> Unit = { _, _ -> },
|
||||||
|
onKill: (Session) -> Unit = {},
|
||||||
|
onSpawnSimilar: (Session) -> Unit = {},
|
||||||
|
) {
|
||||||
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
var renaming by remember { mutableStateOf(false) }
|
||||||
|
var confirmingKill by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val running = session.status == SessionStatus.Running
|
||||||
|
val name = sessionName(session)
|
||||||
|
val muted = isProvisionalName(session)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.combinedClickable(
|
||||||
|
onClick = { onOpen(session) },
|
||||||
|
onLongClick = { menuExpanded = true },
|
||||||
|
)
|
||||||
|
.padding(start = (8 + depthPadding).dp, end = 8.dp, top = 6.dp, bottom = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
ActivityRail(activity = session.activity, modifier = Modifier.height(36.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
color = when {
|
||||||
|
!running -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
muted -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
else -> MaterialTheme.colorScheme.onSurface
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
MetaLine(session = session, nowMs = nowMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Rename") },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
renaming = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Spawn similar") },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
onSpawnSimilar(session)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Kill") },
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
confirmingKill = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renaming) {
|
||||||
|
RenameDialog(
|
||||||
|
initial = customSessionName(session) ?: "",
|
||||||
|
onCommit = {
|
||||||
|
renaming = false
|
||||||
|
onRename(session, it)
|
||||||
|
},
|
||||||
|
onCancel = { renaming = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmingKill) {
|
||||||
|
KillConfirmDialog(
|
||||||
|
session = session,
|
||||||
|
onConfirm = {
|
||||||
|
confirmingKill = false
|
||||||
|
onKill(session)
|
||||||
|
},
|
||||||
|
onCancel = { confirmingKill = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Line 2, left to right: `[duration] [detail] command [diff stat | exited]`
|
||||||
|
* — the line itself is unconditional, only its fragments are adaptive. */
|
||||||
|
@Composable
|
||||||
|
fun MetaLine(session: Session, nowMs: Long, modifier: Modifier = Modifier) {
|
||||||
|
val heldMs = activityHeldMs(session.activity_since, nowMs)
|
||||||
|
val duration = if (heldMs != null && heldMs >= META_LINE_MIN_HOLD_MS) {
|
||||||
|
activityDurationLabel(heldMs)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
val blockers = sessionBlockers(session)
|
||||||
|
|
||||||
|
Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (duration != null) {
|
||||||
|
Text(
|
||||||
|
text = duration,
|
||||||
|
color = activityColor(session.activity),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
}
|
||||||
|
if (session.activity_detail != null) {
|
||||||
|
Text(
|
||||||
|
text = session.activity_detail,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = programName(session.command),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
if (blockers != null) {
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
BlockedFragment(blockers)
|
||||||
|
}
|
||||||
|
if (session.status == SessionStatus.Dead) {
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "exited",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BlockedFragment(blockers: InTheWay) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
blockerStats(blockers).forEachIndexed { index, stat ->
|
||||||
|
if (index > 0) Spacer(Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stat.text,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = when (stat.kind) {
|
||||||
|
"add" -> MaterialTheme.colorScheme.primary
|
||||||
|
"del" -> MaterialTheme.colorScheme.error
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RenameDialog(initial: String, onCommit: (String) -> Unit, onCancel: () -> Unit) {
|
||||||
|
var text by remember { mutableStateOf(initial) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
title = { Text("Rename session") },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = { onCommit(text.trim()) }) { Text("Rename") } },
|
||||||
|
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun KillConfirmDialog(session: Session, onConfirm: () -> Unit, onCancel: () -> Unit) {
|
||||||
|
val blockers = sessionBlockers(session)
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
title = { Text("Kill “${sessionName(session)}”?") },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
if (blockers != null) {
|
||||||
|
"This worktree has unsaved work: ${formatInTheWay(blockers)}. Killing may be refused unless forced."
|
||||||
|
} else {
|
||||||
|
"This ends the session's process. This can't be undone."
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = { TextButton(onClick = onConfirm) { Text("Kill") } },
|
||||||
|
dismissButton = { TextButton(onClick = onCancel) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package pt.naps.maestro.ui.sidebar
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import pt.naps.maestro.data.InTheWay
|
||||||
|
import pt.naps.maestro.data.NameSource
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
|
||||||
|
/** docs/SIDEBAR.md: a state held shorter than this has no duration fragment. */
|
||||||
|
const val META_LINE_MIN_HOLD_MS: Long = 30_000L
|
||||||
|
|
||||||
|
/** Payloads predating #102 default `activity_since` to the Unix epoch;
|
||||||
|
* anything implausibly old is "never observed" rather than a huge duration. */
|
||||||
|
private val PLAUSIBLE_EPOCH_MS = Instant.parse("2010-01-01T00:00:00Z").toEpochMilli()
|
||||||
|
|
||||||
|
/** Milliseconds held in the current activity state, or `null` when `since` is
|
||||||
|
* missing/unparsable/implausible. Mirrors the web's `activityHeldMs`. */
|
||||||
|
fun activityHeldMs(since: String?, nowMs: Long): Long? {
|
||||||
|
if (since.isNullOrEmpty()) return null
|
||||||
|
val t = runCatching { Instant.parse(since).toEpochMilli() }.getOrNull() ?: return null
|
||||||
|
if (t < PLAUSIBLE_EPOCH_MS) return null
|
||||||
|
return (nowMs - t).coerceAtLeast(0L)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `<60s` whole seconds, `<60m` whole minutes, else whole hours — floored
|
||||||
|
* everywhere so the counter never shows a duration the state hasn't reached. */
|
||||||
|
fun activityDurationLabel(heldMs: Long): String {
|
||||||
|
val seconds = heldMs / 1000
|
||||||
|
if (seconds < 60) return "${seconds}s"
|
||||||
|
val minutes = seconds / 60
|
||||||
|
if (minutes < 60) return "${minutes}m"
|
||||||
|
return "${minutes / 60}h"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The session's own name (#125), without the command fallback: `null` for a
|
||||||
|
* session that was never given one. */
|
||||||
|
fun customSessionName(session: Session): String? {
|
||||||
|
val element = session.metadata["name"] ?: return null
|
||||||
|
val primitive = element as? JsonPrimitive ?: return null
|
||||||
|
if (!primitive.isString) return null
|
||||||
|
return primitive.content.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Line 1's name: `metadata.name` if present, else the command line. */
|
||||||
|
fun sessionName(session: Session): String = customSessionName(session) ?: session.command
|
||||||
|
|
||||||
|
/** Where the session's name came from (#131), read leniently: anything
|
||||||
|
* unrecognised — including a session named before the field existed — reads
|
||||||
|
* as [NameSource.User], so an existing name is never dimmed or overwritten. */
|
||||||
|
fun sessionNameSource(session: Session): NameSource {
|
||||||
|
val element = session.metadata["name_source"] as? JsonPrimitive ?: return NameSource.User
|
||||||
|
return when (element.jsonPrimitive.content) {
|
||||||
|
"Generated" -> NameSource.Generated
|
||||||
|
"Haiku" -> NameSource.Haiku
|
||||||
|
else -> NameSource.User
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether line 1 renders the name muted (#131): a handle the daemon
|
||||||
|
* generated at spawn and may still replace with one derived from the first
|
||||||
|
* prompt. */
|
||||||
|
fun isProvisionalName(session: Session): Boolean =
|
||||||
|
customSessionName(session) != null && sessionNameSource(session) == NameSource.Generated
|
||||||
|
|
||||||
|
/** One fragment of the compact blocked-work stat (docs/SIDEBAR.md). `kind`
|
||||||
|
* picks the colour; `text` is what the card prints. */
|
||||||
|
data class BlockerStat(val kind: String, val text: String)
|
||||||
|
|
||||||
|
/** #83: this session's blocked-work breakdown when something actually blocks
|
||||||
|
* an unforced kill; `null` while clear or not yet probed. */
|
||||||
|
fun sessionBlockers(session: Session): InTheWay? {
|
||||||
|
val w = session.blocked_work ?: return null
|
||||||
|
val blocked = w.uncommitted_files > 0 || w.untracked_files > 0 || w.unpushed_commits > 0
|
||||||
|
return if (blocked) w else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The blocked-work breakdown as diff notation: `+124 -18 ?3 ↑2`. A tree whose
|
||||||
|
* only tracked change carries no line delta falls back to the file count
|
||||||
|
* (`~2`) rather than claiming `+0 -0`. */
|
||||||
|
fun blockerStats(w: InTheWay): List<BlockerStat> {
|
||||||
|
val stats = mutableListOf<BlockerStat>()
|
||||||
|
if (w.insertions > 0 || w.deletions > 0) {
|
||||||
|
if (w.insertions > 0) stats += BlockerStat("add", "+${w.insertions}")
|
||||||
|
if (w.deletions > 0) stats += BlockerStat("del", "-${w.deletions}")
|
||||||
|
} else if (w.uncommitted_files > 0) {
|
||||||
|
stats += BlockerStat("files", "~${w.uncommitted_files}")
|
||||||
|
}
|
||||||
|
if (w.untracked_files > 0) stats += BlockerStat("untracked", "?${w.untracked_files}")
|
||||||
|
if (w.unpushed_commits > 0) stats += BlockerStat("unpushed", "↑${w.unpushed_commits}")
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The spelled-out phrasing shared by the kill refusal and every client's
|
||||||
|
* tooltip, so no two describe the same tree differently. */
|
||||||
|
fun formatInTheWay(w: InTheWay): String {
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
fun push(n: Int, unit: String) {
|
||||||
|
if (n <= 0) return
|
||||||
|
parts += if (n == 1) "1 $unit" else "$n ${unit}s"
|
||||||
|
}
|
||||||
|
push(w.uncommitted_files, "uncommitted file")
|
||||||
|
push(w.untracked_files, "untracked file")
|
||||||
|
push(w.unpushed_commits, "unpushed commit")
|
||||||
|
return if (parts.isNotEmpty()) parts.joinToString(", ") else "no unsaved work"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val ENV_ASSIGNMENT = Regex("^[A-Za-z_][A-Za-z0-9_]*=.*$")
|
||||||
|
private val PLAIN_WRAPPERS = setOf("sudo", "env")
|
||||||
|
private val DLX_CAPABLE = setOf("pnpm", "yarn")
|
||||||
|
|
||||||
|
/** The program a command line runs — never its arguments (docs/SIDEBAR.md).
|
||||||
|
* Looks through leading `FOO=bar` assignments, `sudo`/`env` wrappers (and
|
||||||
|
* their own flags), and JS-toolchain launchers (`npx`, `bunx`, `pnpm`/`yarn
|
||||||
|
* dlx`), then basenames what is left. A command that is nothing but wrappers
|
||||||
|
* is returned as written rather than as an empty string. */
|
||||||
|
fun programName(command: String): String {
|
||||||
|
val trimmed = command.trim()
|
||||||
|
val words = trimmed.split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||||
|
if (words.isEmpty()) return trimmed
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (i < words.size && ENV_ASSIGNMENT.matches(words[i])) i++
|
||||||
|
while (i < words.size && words[i] in PLAIN_WRAPPERS) {
|
||||||
|
i++
|
||||||
|
while (i < words.size && (ENV_ASSIGNMENT.matches(words[i]) || words[i].startsWith("-"))) i++
|
||||||
|
}
|
||||||
|
if (i >= words.size) return basename(words.last())
|
||||||
|
|
||||||
|
when {
|
||||||
|
words[i] in DLX_CAPABLE && words.getOrNull(i + 1) == "dlx" -> i += 2
|
||||||
|
words[i] == "npx" || words[i] == "bunx" -> i += 1
|
||||||
|
words[i] == "bun" && words.getOrNull(i + 1) == "x" -> i += 2
|
||||||
|
}
|
||||||
|
val program = words.getOrNull(i) ?: return basename(words.last())
|
||||||
|
return basename(program)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun basename(path: String): String = path.substringAfterLast('/')
|
||||||
|
|
||||||
|
/** Sessions are grouped by the repository they belong to (#265): a
|
||||||
|
* worktree's `base_repo`, or the session's own `cwd` when it owns none. */
|
||||||
|
fun sessionGroupKey(session: Session): String = session.worktree?.base_repo ?: session.cwd
|
||||||
|
|
||||||
|
/** One folder in the sidebar tree: a repo/cwd group and the sessions in it,
|
||||||
|
* sorted by name. */
|
||||||
|
data class SessionGroup(val key: String, val label: String, val sessions: List<Session>)
|
||||||
|
|
||||||
|
/** Group sessions by [sessionGroupKey], each group sorted by name and the
|
||||||
|
* groups themselves sorted by label. */
|
||||||
|
fun groupSessions(sessions: Collection<Session>): List<SessionGroup> =
|
||||||
|
sessions
|
||||||
|
.groupBy(::sessionGroupKey)
|
||||||
|
.map { (key, members) ->
|
||||||
|
SessionGroup(
|
||||||
|
key = key,
|
||||||
|
label = key.trimEnd('/').substringAfterLast('/').ifEmpty { key },
|
||||||
|
sessions = members.sortedBy { sessionName(it).lowercase() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedBy { it.label.lowercase() }
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package pt.naps.maestro.ui.sidebar
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import pt.naps.maestro.connection.CatalogStore
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
|
||||||
|
/** A clock that advances at least once every 30s (docs/SIDEBAR.md), ticked
|
||||||
|
* faster than that so a duration's cutover lands within one visible frame. */
|
||||||
|
@Composable
|
||||||
|
fun rememberTickingNowMs(periodMs: Long = 5_000L): Long {
|
||||||
|
var now by remember { mutableStateOf(System.currentTimeMillis()) }
|
||||||
|
LaunchedEffect(periodMs) {
|
||||||
|
while (true) {
|
||||||
|
delay(periodMs)
|
||||||
|
now = System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Assembles the full sidebar from the [CatalogStore] built in #264: sessions
|
||||||
|
* grouped into collapsible folders by repository, each rendered as a
|
||||||
|
* two-line [SessionCard] behind its [ActivityRail]. */
|
||||||
|
@Composable
|
||||||
|
fun SidebarContent(
|
||||||
|
catalogStore: CatalogStore,
|
||||||
|
onOpenSession: (Session) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onRenameSession: (Uuid, String) -> Unit = { _, _ -> },
|
||||||
|
onKillSession: (Uuid) -> Unit = {},
|
||||||
|
onSpawnSimilar: (Session) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val sessions by catalogStore.sessions.collectAsState()
|
||||||
|
SidebarContent(
|
||||||
|
sessions = sessions.values.toList(),
|
||||||
|
onOpenSession = onOpenSession,
|
||||||
|
modifier = modifier,
|
||||||
|
onRenameSession = onRenameSession,
|
||||||
|
onKillSession = onKillSession,
|
||||||
|
onSpawnSimilar = onSpawnSimilar,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same as the [CatalogStore]-driven overload, but takes the session list
|
||||||
|
* directly — the shape used by tests and previews. */
|
||||||
|
@Composable
|
||||||
|
fun SidebarContent(
|
||||||
|
sessions: List<Session>,
|
||||||
|
onOpenSession: (Session) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onRenameSession: (Uuid, String) -> Unit = { _, _ -> },
|
||||||
|
onKillSession: (Uuid) -> Unit = {},
|
||||||
|
onSpawnSimilar: (Session) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val nowMs = rememberTickingNowMs()
|
||||||
|
val groups = remember(sessions) { groupSessions(sessions) }
|
||||||
|
val collapsed = remember { mutableStateMapOf<String, Boolean>() }
|
||||||
|
|
||||||
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
|
Text(
|
||||||
|
text = "Sessions",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
if (groups.isEmpty()) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "No sessions",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
for (group in groups) {
|
||||||
|
val isCollapsed = collapsed[group.key] == true
|
||||||
|
item(key = "folder:${group.key}") {
|
||||||
|
FolderNode(
|
||||||
|
label = group.label,
|
||||||
|
collapsed = isCollapsed,
|
||||||
|
onToggle = { collapsed[group.key] = !isCollapsed },
|
||||||
|
depthPadding = 8,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
for (session in group.sessions) {
|
||||||
|
SessionCard(
|
||||||
|
session = session,
|
||||||
|
nowMs = nowMs,
|
||||||
|
depthPadding = 16,
|
||||||
|
onOpen = onOpenSession,
|
||||||
|
onRename = { s, name -> onRenameSession(s.id, name) },
|
||||||
|
onKill = { s -> onKillSession(s.id) },
|
||||||
|
onSpawnSimilar = onSpawnSimilar,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package pt.naps.maestro.ui.spawn
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import pt.naps.maestro.data.CreateSessionRequest
|
||||||
|
import pt.naps.maestro.data.SessionView
|
||||||
|
import pt.naps.maestro.data.SpawnPreset
|
||||||
|
import pt.naps.maestro.data.WorktreeSpec
|
||||||
|
|
||||||
|
/** Tools whose sessions can render as a structured (ACP) transcript instead
|
||||||
|
* of a raw terminal. Mirrors `STRUCTURED_VIEW_TOOLS` in
|
||||||
|
* `web/src/components/spawn/model.ts`. */
|
||||||
|
private val STRUCTURED_VIEW_TOOLS = setOf("claude", "codex")
|
||||||
|
|
||||||
|
/** Commands that just launch another program, looked through to find the
|
||||||
|
* real one: `env -i codex` and `sudo codex` both resolve to `codex`. */
|
||||||
|
private val WRAPPER_PROGRAMS = setOf("env", "sudo", "nohup", "command")
|
||||||
|
|
||||||
|
private val ENV_ASSIGNMENT = Regex("[A-Za-z_][A-Za-z0-9_]*=.*")
|
||||||
|
|
||||||
|
/** Program a command line would run, stripped of leading `FOO=bar`
|
||||||
|
* assignments, known wrappers, its own arguments, and any path prefix. A
|
||||||
|
* simplified stand-in for the shell-grammar-aware `splitCommand` the web
|
||||||
|
* client uses — good enough for the preset commands and the common
|
||||||
|
* `env FOO=1 claude` shapes the spawn form actually produces. */
|
||||||
|
internal fun resolveProgram(command: String): String {
|
||||||
|
val words = command.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }
|
||||||
|
var i = 0
|
||||||
|
while (i < words.size) {
|
||||||
|
while (i < words.size && words[i].matches(ENV_ASSIGNMENT)) i++
|
||||||
|
if (i >= words.size) return ""
|
||||||
|
val word = words[i].substringAfterLast('/').lowercase()
|
||||||
|
if (word !in WRAPPER_PROGRAMS) return word
|
||||||
|
i++
|
||||||
|
while (i < words.size && words[i].startsWith("-")) i++
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
fun supportsStructuredView(command: String): Boolean = resolveProgram(command) in STRUCTURED_VIEW_TOOLS
|
||||||
|
|
||||||
|
/** Where a worktree's branch comes from: freshly created (default) or picked
|
||||||
|
* from the repository's existing branches. Mirrors `WorktreeBranchMode` in
|
||||||
|
* `web/src/components/spawn/model.ts`. */
|
||||||
|
enum class WorktreeBranchMode { New, Existing }
|
||||||
|
|
||||||
|
/** The spawn form's current values, gathered at submit time. Mirrors
|
||||||
|
* `SpawnFormValues` in `web/src/components/spawn/model.ts`. */
|
||||||
|
data class SpawnFormValues(
|
||||||
|
val command: String,
|
||||||
|
val cwd: String,
|
||||||
|
val name: String,
|
||||||
|
val structuredView: Boolean,
|
||||||
|
val autoApprove: Boolean,
|
||||||
|
val worktreeEnabled: Boolean,
|
||||||
|
val worktreeBranch: String,
|
||||||
|
val worktreeBranchMode: WorktreeBranchMode,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface SpawnBuildResult {
|
||||||
|
data class Ok(val request: CreateSessionRequest) : SpawnBuildResult
|
||||||
|
data class Error(val message: String) : SpawnBuildResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Turns validated form values into a `POST /api/sessions` body, or an error
|
||||||
|
* message when something required is missing. Mirrors `buildSpawnRequest` in
|
||||||
|
* `web/src/components/spawn/model.ts`. */
|
||||||
|
fun buildSpawnRequest(values: SpawnFormValues, baseRepo: String?): SpawnBuildResult {
|
||||||
|
if (values.cwd.isBlank()) return SpawnBuildResult.Error("Pick a working directory.")
|
||||||
|
if (values.command.isBlank()) return SpawnBuildResult.Error("Pick a command (or fill the custom command).")
|
||||||
|
|
||||||
|
val name = values.name.trim()
|
||||||
|
val metadata = if (name.isNotEmpty()) mapOf("name" to JsonPrimitive(name)) else emptyMap()
|
||||||
|
|
||||||
|
var worktree: WorktreeSpec? = null
|
||||||
|
if (values.worktreeEnabled) {
|
||||||
|
val branch = values.worktreeBranch.trim()
|
||||||
|
if (baseRepo.isNullOrBlank()) {
|
||||||
|
return SpawnBuildResult.Error("Worktree needs a directory inside a git repository.")
|
||||||
|
}
|
||||||
|
if (branch.isEmpty()) {
|
||||||
|
return SpawnBuildResult.Error(
|
||||||
|
if (values.worktreeBranchMode == WorktreeBranchMode.Existing) "Pick an existing branch."
|
||||||
|
else "Worktree requires a branch name.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
worktree = WorktreeSpec(
|
||||||
|
base_repo = baseRepo,
|
||||||
|
branch = branch,
|
||||||
|
create_branch = values.worktreeBranchMode != WorktreeBranchMode.Existing,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return SpawnBuildResult.Ok(
|
||||||
|
CreateSessionRequest(
|
||||||
|
cwd = values.cwd,
|
||||||
|
command = values.command,
|
||||||
|
metadata = metadata,
|
||||||
|
worktree = worktree,
|
||||||
|
view = if (values.structuredView && supportsStructuredView(values.command)) {
|
||||||
|
SessionView.Structured
|
||||||
|
} else {
|
||||||
|
SessionView.Terminal
|
||||||
|
},
|
||||||
|
auto_approve = values.autoApprove,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Built-ins shown when the daemon's `/api/info` presets haven't loaded yet
|
||||||
|
* (or can't be fetched). Mirrors `FALLBACK_PRESETS` in
|
||||||
|
* `web/src/components/spawn/model.ts`, reordered per #269's chip row. */
|
||||||
|
val FALLBACK_PRESETS: List<SpawnPreset> = listOf(
|
||||||
|
SpawnPreset(id = "claude", label = "Claude", command = "claude"),
|
||||||
|
SpawnPreset(id = "codex", label = "Codex", command = "codex"),
|
||||||
|
SpawnPreset(id = "zsh", label = "Zsh", command = "zsh"),
|
||||||
|
SpawnPreset(id = "custom", label = "Custom…", command = ""),
|
||||||
|
)
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
package pt.naps.maestro.ui.spawn
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.SheetState
|
||||||
|
import androidx.compose.material3.Snackbar
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import pt.naps.maestro.data.BranchInfo
|
||||||
|
import pt.naps.maestro.data.Project
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SpawnPreset
|
||||||
|
|
||||||
|
/** Bottom sheet spawn form (#269), matching the web spawn modal's flow:
|
||||||
|
* a preset chip row, a searchable project list, a raw-path autocomplete
|
||||||
|
* fallback, a branch/worktree picker, a session name, and the
|
||||||
|
* structured-view toggle. [SpawnSheet] wires this to a live [SpawnViewModel];
|
||||||
|
* [SpawnSheetContent] is the pure, state-driven body used by previews and
|
||||||
|
* tests. */
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SpawnSheet(
|
||||||
|
viewModel: SpawnViewModel,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSpawned: (Session) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, modifier = modifier) {
|
||||||
|
SpawnSheetContent(
|
||||||
|
state = state,
|
||||||
|
onSelectPreset = viewModel::selectPreset,
|
||||||
|
onCustomCommandChange = viewModel::setCustomCommand,
|
||||||
|
onStructuredViewChange = viewModel::setStructuredView,
|
||||||
|
onProjectQueryChange = viewModel::setProjectQuery,
|
||||||
|
onSelectProject = viewModel::selectProject,
|
||||||
|
onCwdChange = viewModel::setCwd,
|
||||||
|
onPickDirSuggestion = viewModel::pickDirSuggestion,
|
||||||
|
onWorktreeEnabledChange = viewModel::setWorktreeEnabled,
|
||||||
|
onBranchModeChange = viewModel::setBranchMode,
|
||||||
|
onNewBranchChange = viewModel::setNewBranch,
|
||||||
|
onExistingBranchChange = viewModel::setExistingBranch,
|
||||||
|
onNameChange = viewModel::setName,
|
||||||
|
onReroll = viewModel::rerollName,
|
||||||
|
onDismissError = viewModel::dismissError,
|
||||||
|
onSubmit = { viewModel.submit(onSpawned) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SpawnSheetContent(
|
||||||
|
state: SpawnUiState,
|
||||||
|
onSelectPreset: (String) -> Unit,
|
||||||
|
onCustomCommandChange: (String) -> Unit,
|
||||||
|
onStructuredViewChange: (Boolean) -> Unit,
|
||||||
|
onProjectQueryChange: (String) -> Unit,
|
||||||
|
onSelectProject: (Project) -> Unit,
|
||||||
|
onCwdChange: (String) -> Unit,
|
||||||
|
onPickDirSuggestion: (String) -> Unit,
|
||||||
|
onWorktreeEnabledChange: (Boolean) -> Unit,
|
||||||
|
onBranchModeChange: (WorktreeBranchMode) -> Unit,
|
||||||
|
onNewBranchChange: (String) -> Unit,
|
||||||
|
onExistingBranchChange: (String) -> Unit,
|
||||||
|
onNameChange: (String) -> Unit,
|
||||||
|
onReroll: () -> Unit,
|
||||||
|
onDismissError: () -> Unit,
|
||||||
|
onSubmit: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
LaunchedEffect(state.error) {
|
||||||
|
val message = state.error ?: return@LaunchedEffect
|
||||||
|
snackbarHostState.showSnackbar(message)
|
||||||
|
onDismissError()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text("Spawn session", style = MaterialTheme.typography.titleMedium)
|
||||||
|
|
||||||
|
PresetRow(
|
||||||
|
presets = state.presets,
|
||||||
|
selectedPresetId = state.selectedPresetId,
|
||||||
|
onSelectPreset = onSelectPreset,
|
||||||
|
)
|
||||||
|
if (state.isCustomSelected) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.customCommand,
|
||||||
|
onValueChange = onCustomCommandChange,
|
||||||
|
label = { Text("Command") },
|
||||||
|
placeholder = { Text("bash -l") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ProjectPicker(
|
||||||
|
query = state.projectQuery,
|
||||||
|
onQueryChange = onProjectQueryChange,
|
||||||
|
projects = state.visibleProjects,
|
||||||
|
loading = state.projectsLoading,
|
||||||
|
selectedPath = state.selectedProjectPath,
|
||||||
|
homeDir = state.homeDir,
|
||||||
|
onSelectProject = onSelectProject,
|
||||||
|
)
|
||||||
|
|
||||||
|
DirAutocompleteField(
|
||||||
|
cwd = state.cwd,
|
||||||
|
suggestions = state.dirSuggestions,
|
||||||
|
onCwdChange = onCwdChange,
|
||||||
|
onPickSuggestion = onPickDirSuggestion,
|
||||||
|
)
|
||||||
|
|
||||||
|
BranchSection(
|
||||||
|
enabled = state.worktreeEnabled,
|
||||||
|
onEnabledChange = onWorktreeEnabledChange,
|
||||||
|
baseRepo = state.baseRepo,
|
||||||
|
mode = state.branchMode,
|
||||||
|
onModeChange = onBranchModeChange,
|
||||||
|
newBranch = state.newBranch,
|
||||||
|
onNewBranchChange = onNewBranchChange,
|
||||||
|
branches = state.branches,
|
||||||
|
branchesLoading = state.branchesLoading,
|
||||||
|
existingBranch = state.effectiveExistingBranch,
|
||||||
|
onExistingBranchChange = onExistingBranchChange,
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.name,
|
||||||
|
onValueChange = onNameChange,
|
||||||
|
label = { Text("Session name") },
|
||||||
|
placeholder = { Text("named after your first prompt") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
TextButton(onClick = onReroll) { Text("Reroll") }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supportsStructuredView(state.command)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = state.structuredView, onCheckedChange = onStructuredViewChange)
|
||||||
|
Text("Structured view")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onSubmit,
|
||||||
|
enabled = !state.submitting,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
if (state.submitting) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp)
|
||||||
|
} else {
|
||||||
|
Text("Spawn")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SnackbarHost(hostState = snackbarHostState, modifier = Modifier.align(Alignment.BottomCenter)) {
|
||||||
|
Snackbar(snackbarData = it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A row is its own small touch target: tap anywhere in it, not just the
|
||||||
|
* leading control, to select it. */
|
||||||
|
private fun Modifier.clickableRow(enabled: Boolean = true, onClick: () -> Unit): Modifier =
|
||||||
|
this.clickable(enabled = enabled, onClick = onClick)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PresetRow(presets: List<SpawnPreset>, selectedPresetId: String, onSelectPreset: (String) -> Unit) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text("Harness", style = MaterialTheme.typography.labelLarge)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
for (preset in presets) {
|
||||||
|
val label = if (preset.command.isEmpty()) "Custom…" else preset.label
|
||||||
|
FilterChip(
|
||||||
|
selected = preset.id == selectedPresetId,
|
||||||
|
onClick = { onSelectPreset(preset.id) },
|
||||||
|
label = { Text(label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProjectPicker(
|
||||||
|
query: String,
|
||||||
|
onQueryChange: (String) -> Unit,
|
||||||
|
projects: List<Project>,
|
||||||
|
loading: Boolean,
|
||||||
|
selectedPath: String?,
|
||||||
|
homeDir: String,
|
||||||
|
onSelectProject: (Project) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text("Project", style = MaterialTheme.typography.labelLarge)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
placeholder = { Text("Filter projects…") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
when {
|
||||||
|
loading -> Text(
|
||||||
|
"Loading projects…",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
projects.isEmpty() -> Text(
|
||||||
|
"No projects match.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
else -> LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 220.dp)) {
|
||||||
|
items(projects, key = { it.path }) { project ->
|
||||||
|
ProjectRow(
|
||||||
|
project = project,
|
||||||
|
active = project.path == selectedPath,
|
||||||
|
homeDir = homeDir,
|
||||||
|
onClick = { onSelectProject(project) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProjectRow(project: Project, active: Boolean, homeDir: String, onClick: () -> Unit) {
|
||||||
|
val background = if (active) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.background(background)
|
||||||
|
.clickableRow(onClick = onClick)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
Text(project.name, style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(
|
||||||
|
abbreviateHome(parentDir(project.path), homeDir),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun abbreviateHome(path: String, homeDir: String): String =
|
||||||
|
if (homeDir.isNotEmpty() && path.startsWith(homeDir)) "~${path.removePrefix(homeDir)}" else path
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DirAutocompleteField(
|
||||||
|
cwd: String,
|
||||||
|
suggestions: List<String>,
|
||||||
|
onCwdChange: (String) -> Unit,
|
||||||
|
onPickSuggestion: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = cwd,
|
||||||
|
onValueChange = onCwdChange,
|
||||||
|
label = { Text("Or type a directory") },
|
||||||
|
placeholder = { Text("/home/user/project") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
if (suggestions.isNotEmpty()) {
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 160.dp)) {
|
||||||
|
items(suggestions) { entry ->
|
||||||
|
Text(
|
||||||
|
text = "$entry/",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickableRow { onPickSuggestion(entry) }
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BranchSection(
|
||||||
|
enabled: Boolean,
|
||||||
|
onEnabledChange: (Boolean) -> Unit,
|
||||||
|
baseRepo: String?,
|
||||||
|
mode: WorktreeBranchMode,
|
||||||
|
onModeChange: (WorktreeBranchMode) -> Unit,
|
||||||
|
newBranch: String,
|
||||||
|
onNewBranchChange: (String) -> Unit,
|
||||||
|
branches: List<BranchInfo>,
|
||||||
|
branchesLoading: Boolean,
|
||||||
|
existingBranch: String,
|
||||||
|
onExistingBranchChange: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = enabled, onCheckedChange = onEnabledChange)
|
||||||
|
Text("Create git worktree")
|
||||||
|
}
|
||||||
|
if (!enabled) return@Column
|
||||||
|
|
||||||
|
if (baseRepo == null) {
|
||||||
|
Text(
|
||||||
|
"Choose a directory inside a git repository first.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
Text("repo: $baseRepo", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
RadioButton(selected = mode == WorktreeBranchMode.New, onClick = { onModeChange(WorktreeBranchMode.New) })
|
||||||
|
Text("New branch")
|
||||||
|
Spacer(Modifier.width(16.dp))
|
||||||
|
RadioButton(selected = mode == WorktreeBranchMode.Existing, onClick = { onModeChange(WorktreeBranchMode.Existing) })
|
||||||
|
Text("Existing branch")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == WorktreeBranchMode.New) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = newBranch,
|
||||||
|
onValueChange = onNewBranchChange,
|
||||||
|
label = { Text("New branch name") },
|
||||||
|
placeholder = { Text("feat/foo") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
when {
|
||||||
|
branchesLoading -> Text(
|
||||||
|
"Loading branches…",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
branches.isEmpty() -> Text(
|
||||||
|
"No local branches found.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
else -> LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 180.dp)) {
|
||||||
|
items(branches, key = { it.name }) { branch ->
|
||||||
|
val held = branch.worktree ?: (if (branch.is_head) "the main checkout" else null)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickableRow(enabled = held == null) { onExistingBranchChange(branch.name) }
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = branch.name == existingBranch,
|
||||||
|
onClick = { onExistingBranchChange(branch.name) },
|
||||||
|
enabled = held == null,
|
||||||
|
)
|
||||||
|
Column {
|
||||||
|
Text(branch.name, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
if (held != null) {
|
||||||
|
Text(
|
||||||
|
"held by $held",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package pt.naps.maestro.ui.spawn
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import pt.naps.maestro.api.MaestroApiException
|
||||||
|
import pt.naps.maestro.api.MaestroClient
|
||||||
|
import pt.naps.maestro.data.BranchInfo
|
||||||
|
import pt.naps.maestro.data.Project
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SpawnPreset
|
||||||
|
|
||||||
|
/** Quiet period after the operator types a raw path before `/api/dirs` and
|
||||||
|
* `/api/gitroot` fire (#269). */
|
||||||
|
private const val DIR_AUTOCOMPLETE_DEBOUNCE_MS = 300L
|
||||||
|
|
||||||
|
/** Everything above the last segment of an absolute path: `/a/b/c` -> `/a/b`.
|
||||||
|
* A top-level path keeps `/`, and a trailing slash is ignored. Mirrors
|
||||||
|
* `parentDir` in `web/src/paths.ts`. */
|
||||||
|
internal fun parentDir(path: String): String {
|
||||||
|
val trimmed = if (path.length > 1 && path.endsWith("/")) path.dropLast(1) else path
|
||||||
|
val slash = trimmed.lastIndexOf('/')
|
||||||
|
return when {
|
||||||
|
slash < 0 -> ""
|
||||||
|
slash == 0 -> "/"
|
||||||
|
else -> trimmed.substring(0, slash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits a path into its directory and trailing (possibly partial) segment.
|
||||||
|
* Mirrors `splitPath` in `web/src/paths.ts`. */
|
||||||
|
internal fun splitPath(value: String): Pair<String, String> {
|
||||||
|
val slash = value.lastIndexOf('/')
|
||||||
|
return if (slash == -1) "" to value else value.substring(0, slash + 1) to value.substring(slash + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SpawnUiState(
|
||||||
|
val presets: List<SpawnPreset> = FALLBACK_PRESETS,
|
||||||
|
val selectedPresetId: String = "",
|
||||||
|
val customCommand: String = "",
|
||||||
|
val commandTouched: Boolean = false,
|
||||||
|
val structuredView: Boolean = false,
|
||||||
|
val structuredViewTouched: Boolean = false,
|
||||||
|
val homeDir: String = "",
|
||||||
|
val projects: List<Project> = emptyList(),
|
||||||
|
val projectsLoading: Boolean = true,
|
||||||
|
val projectQuery: String = "",
|
||||||
|
val selectedProjectPath: String? = null,
|
||||||
|
val cwd: String = "",
|
||||||
|
val dirSuggestions: List<String> = emptyList(),
|
||||||
|
val baseRepo: String? = null,
|
||||||
|
val worktreeEnabled: Boolean = false,
|
||||||
|
val branchMode: WorktreeBranchMode = WorktreeBranchMode.New,
|
||||||
|
val newBranch: String = "",
|
||||||
|
val existingBranch: String = "",
|
||||||
|
val branches: List<BranchInfo> = emptyList(),
|
||||||
|
val branchesLoading: Boolean = false,
|
||||||
|
val name: String = "",
|
||||||
|
val submitting: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
) {
|
||||||
|
val activePreset: SpawnPreset? get() = presets.find { it.id == selectedPresetId }
|
||||||
|
val isCustomSelected: Boolean get() = activePreset == null || activePreset?.command == ""
|
||||||
|
val command: String get() = if (isCustomSelected) customCommand.trim() else activePreset?.command.orEmpty()
|
||||||
|
|
||||||
|
val visibleProjects: List<Project>
|
||||||
|
get() {
|
||||||
|
val query = projectQuery.trim().lowercase()
|
||||||
|
val filtered = if (query.isEmpty()) {
|
||||||
|
projects
|
||||||
|
} else {
|
||||||
|
projects.filter { it.name.lowercase().contains(query) || it.path.lowercase().contains(query) }
|
||||||
|
}
|
||||||
|
return filtered.sortedByDescending { runCatching { Instant.parse(it.last_activity) }.getOrNull() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val effectiveExistingBranch: String
|
||||||
|
get() = existingBranch.ifEmpty {
|
||||||
|
branches.firstOrNull { it.worktree == null && !it.is_head }?.name.orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form state and API calls behind [SpawnSheet]. A plain class rather than an
|
||||||
|
* `androidx.lifecycle.ViewModel` — the sheet's own `remember` scope is enough
|
||||||
|
* for a short-lived form, and this keeps the class trivially testable with a
|
||||||
|
* `TestScope`, the same shape as [pt.naps.maestro.connection.DaemonLink].
|
||||||
|
*/
|
||||||
|
class SpawnViewModel(
|
||||||
|
private val client: MaestroClient,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
|
) {
|
||||||
|
private val _state = MutableStateFlow(SpawnUiState())
|
||||||
|
val state: StateFlow<SpawnUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var dirJob: Job? = null
|
||||||
|
private var branchesJob: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch {
|
||||||
|
val info = runCatching { client.getDaemonInfo() }.getOrNull()
|
||||||
|
_state.update { s ->
|
||||||
|
val presets = info?.presets?.takeIf { it.isNotEmpty() } ?: FALLBACK_PRESETS
|
||||||
|
val seeded = if (!s.commandTouched && s.selectedPresetId.isEmpty()) {
|
||||||
|
presets.firstOrNull { it.command.isNotEmpty() }?.id ?: s.selectedPresetId
|
||||||
|
} else {
|
||||||
|
s.selectedPresetId
|
||||||
|
}
|
||||||
|
s.copy(homeDir = info?.home_dir.orEmpty(), presets = presets, selectedPresetId = seeded)
|
||||||
|
.withSyncedStructuredView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
val projects = runCatching { client.listProjects() }.getOrDefault(emptyList())
|
||||||
|
_state.update { it.copy(projects = projects, projectsLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectPreset(id: String) {
|
||||||
|
_state.update { it.copy(selectedPresetId = id, commandTouched = true).withSyncedStructuredView() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCustomCommand(text: String) {
|
||||||
|
_state.update { it.copy(customCommand = text, commandTouched = true).withSyncedStructuredView() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setStructuredView(enabled: Boolean) {
|
||||||
|
_state.update { it.copy(structuredView = enabled, structuredViewTouched = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-derives the structured-view checkbox from the command this state
|
||||||
|
* carries, unless the operator has already ticked/unticked it by hand. */
|
||||||
|
private fun SpawnUiState.withSyncedStructuredView(): SpawnUiState =
|
||||||
|
if (structuredViewTouched) this else copy(structuredView = supportsStructuredView(command))
|
||||||
|
|
||||||
|
fun setProjectQuery(query: String) {
|
||||||
|
_state.update { it.copy(projectQuery = query) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectProject(project: Project) {
|
||||||
|
dirJob?.cancel()
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
selectedProjectPath = project.path,
|
||||||
|
cwd = project.path,
|
||||||
|
dirSuggestions = emptyList(),
|
||||||
|
baseRepo = project.base_repo ?: project.path,
|
||||||
|
branches = emptyList(),
|
||||||
|
existingBranch = "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The operator typed a path by hand: clears any project pick, and
|
||||||
|
* schedules a debounced refresh of both the directory-suggestion
|
||||||
|
* dropdown and the git-root probe that resolves [SpawnUiState.baseRepo]. */
|
||||||
|
fun setCwd(raw: String) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
cwd = raw,
|
||||||
|
selectedProjectPath = null,
|
||||||
|
baseRepo = null,
|
||||||
|
branches = emptyList(),
|
||||||
|
existingBranch = "",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
dirJob?.cancel()
|
||||||
|
dirJob = scope.launch {
|
||||||
|
delay(DIR_AUTOCOMPLETE_DEBOUNCE_MS)
|
||||||
|
refreshDirSuggestions(raw)
|
||||||
|
refreshBaseRepo(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appends [entry] as the next path segment, matching the `cd dir/sub`
|
||||||
|
* drill-in gesture the web `DirAutocomplete` uses. */
|
||||||
|
fun pickDirSuggestion(entry: String) {
|
||||||
|
val (dir, _) = splitPath(_state.value.cwd)
|
||||||
|
setCwd("$dir$entry/")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshDirSuggestions(raw: String) {
|
||||||
|
val (dir, prefix) = splitPath(raw)
|
||||||
|
val entries = runCatching { client.listDir(dir).entries }.getOrDefault(emptyList())
|
||||||
|
if (_state.value.cwd != raw) return // a newer edit superseded this one
|
||||||
|
val filtered = entries.filter { it.startsWith(prefix, ignoreCase = true) }
|
||||||
|
_state.update { it.copy(dirSuggestions = filtered) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun refreshBaseRepo(raw: String) {
|
||||||
|
val root = runCatching { client.gitRoot(raw).root }.getOrNull()
|
||||||
|
if (_state.value.cwd != raw) return // a newer edit superseded this one
|
||||||
|
_state.update { it.copy(baseRepo = root) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWorktreeEnabled(enabled: Boolean) {
|
||||||
|
_state.update { it.copy(worktreeEnabled = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setBranchMode(mode: WorktreeBranchMode) {
|
||||||
|
_state.update { it.copy(branchMode = mode) }
|
||||||
|
if (mode == WorktreeBranchMode.Existing) loadBranchesIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setNewBranch(text: String) {
|
||||||
|
_state.update { it.copy(newBranch = text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setExistingBranch(text: String) {
|
||||||
|
_state.update { it.copy(existingBranch = text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadBranchesIfNeeded() {
|
||||||
|
val cwd = _state.value.cwd
|
||||||
|
if (cwd.isEmpty() || _state.value.branchesLoading) return
|
||||||
|
branchesJob?.cancel()
|
||||||
|
_state.update { it.copy(branchesLoading = true) }
|
||||||
|
branchesJob = scope.launch {
|
||||||
|
val branches = runCatching { client.listBranches(cwd) }.getOrDefault(emptyList())
|
||||||
|
if (_state.value.cwd != cwd) return@launch // a newer cwd superseded this fetch
|
||||||
|
_state.update { it.copy(branches = branches, branchesLoading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setName(text: String) {
|
||||||
|
_state.update { it.copy(name = text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /api/names?count=1`: replaces the session-name field with a
|
||||||
|
* freshly generated handle. */
|
||||||
|
fun rerollName() {
|
||||||
|
scope.launch {
|
||||||
|
val handle = runCatching { client.fetchNames(1).names.firstOrNull() }.getOrNull()
|
||||||
|
if (handle != null) _state.update { it.copy(name = handle) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates the form, `POST`s it, and calls [onSuccess] with the new
|
||||||
|
* session. Any failure — validation or API — lands in
|
||||||
|
* [SpawnUiState.error] instead. */
|
||||||
|
fun submit(onSuccess: (Session) -> Unit) {
|
||||||
|
val s = _state.value
|
||||||
|
val values = SpawnFormValues(
|
||||||
|
command = s.command,
|
||||||
|
cwd = s.cwd,
|
||||||
|
name = s.name,
|
||||||
|
structuredView = s.structuredView,
|
||||||
|
autoApprove = s.activePreset?.auto_approve ?: false,
|
||||||
|
worktreeEnabled = s.worktreeEnabled,
|
||||||
|
worktreeBranch = if (s.branchMode == WorktreeBranchMode.Existing) s.effectiveExistingBranch else s.newBranch,
|
||||||
|
worktreeBranchMode = s.branchMode,
|
||||||
|
)
|
||||||
|
when (val built = buildSpawnRequest(values, s.baseRepo)) {
|
||||||
|
is SpawnBuildResult.Error -> _state.update { it.copy(error = built.message) }
|
||||||
|
is SpawnBuildResult.Ok -> {
|
||||||
|
_state.update { it.copy(submitting = true, error = null) }
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val session = client.createSession(built.request)
|
||||||
|
_state.update { it.copy(submitting = false) }
|
||||||
|
onSuccess(session)
|
||||||
|
} catch (e: MaestroApiException) {
|
||||||
|
_state.update { it.copy(submitting = false, error = e.message ?: e.detail) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.update { it.copy(submitting = false, error = e.message ?: "Failed to spawn session.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissError() {
|
||||||
|
_state.update { it.copy(error = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package pt.naps.maestro.ui.terminal
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/** One button on the [TerminalKeyBar]. A [Modifier] toggles Ctrl/Alt state
|
||||||
|
* in the terminal page's own JS instead of sending a byte sequence. */
|
||||||
|
sealed class TerminalKeyAction(val label: String) {
|
||||||
|
data class Modifier(val name: String, val display: String) : TerminalKeyAction(display)
|
||||||
|
data class Send(val display: String, val key: String) : TerminalKeyAction(display)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Termux-style extra key row for the on-screen keyboard (issue #266): Tab,
|
||||||
|
* Esc, Ctrl/Alt, and the four cursor arrows. Ctrl/Alt are one-shot modifiers
|
||||||
|
* — armed here, then folded into the very next key (tapped on this bar or
|
||||||
|
* typed on the soft keyboard) by `assets/terminal/index.html`'s own JS.
|
||||||
|
*/
|
||||||
|
val TERMINAL_KEY_ACTIONS: List<TerminalKeyAction> = listOf(
|
||||||
|
TerminalKeyAction.Send("Esc", "Esc"),
|
||||||
|
TerminalKeyAction.Send("Tab", "Tab"),
|
||||||
|
TerminalKeyAction.Modifier("ctrl", "Ctrl"),
|
||||||
|
TerminalKeyAction.Modifier("alt", "Alt"),
|
||||||
|
TerminalKeyAction.Send("↑", "Up"),
|
||||||
|
TerminalKeyAction.Send("↓", "Down"),
|
||||||
|
TerminalKeyAction.Send("←", "Left"),
|
||||||
|
TerminalKeyAction.Send("→", "Right"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TerminalKeyBar(
|
||||||
|
armed: Set<String>,
|
||||||
|
onSend: (String) -> Unit,
|
||||||
|
onToggleModifier: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
for (action in TERMINAL_KEY_ACTIONS) {
|
||||||
|
when (action) {
|
||||||
|
is TerminalKeyAction.Modifier -> {
|
||||||
|
val isArmed = action.name in armed
|
||||||
|
TextButton(onClick = { onToggleModifier(action.name) }) {
|
||||||
|
Text(
|
||||||
|
text = action.display,
|
||||||
|
color = if (isArmed) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is TerminalKeyAction.Send -> {
|
||||||
|
TextButton(onClick = { onSend(action.key) }) {
|
||||||
|
Text(text = action.display)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package pt.naps.maestro.ui.terminal
|
||||||
|
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import pt.naps.maestro.api.Routes
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `file:///android_asset/terminal/index.html` URL a [TerminalWebView]
|
||||||
|
* loads for one session. The embedded xterm.js page reads its `ws` query
|
||||||
|
* param and opens that as its own `WS /api/attach/:id` connection directly
|
||||||
|
* — this object only computes the URL, it never touches the socket.
|
||||||
|
*/
|
||||||
|
object TerminalPageUrl {
|
||||||
|
private const val ASSET_URL = "file:///android_asset/terminal/index.html"
|
||||||
|
|
||||||
|
fun build(daemonBaseUrl: String, sessionId: Uuid): String {
|
||||||
|
val wsUrl = Routes.attachWsUrl(daemonBaseUrl, sessionId)
|
||||||
|
val encoded = URLEncoder.encode(wsUrl, "UTF-8")
|
||||||
|
return "$ASSET_URL?ws=$encoded"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package pt.naps.maestro.ui.terminal
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.isImeVisible
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import pt.naps.maestro.data.Uuid
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen terminal pane for one session (issue #266): a [TerminalWebView]
|
||||||
|
* showing xterm.js bound to `WS /api/attach/:id`, plus the native
|
||||||
|
* [TerminalKeyBar] overlaid above the soft keyboard while it's open.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun TerminalScreen(
|
||||||
|
daemonBaseUrl: String,
|
||||||
|
sessionId: Uuid,
|
||||||
|
sessionTitle: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val pageUrl = remember(daemonBaseUrl, sessionId) { TerminalPageUrl.build(daemonBaseUrl, sessionId) }
|
||||||
|
var controller by remember { mutableStateOf<TerminalWebViewController?>(null) }
|
||||||
|
var armed by remember { mutableStateOf(emptySet<String>()) }
|
||||||
|
val keyboardOpen = WindowInsets.isImeVisible
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(sessionTitle) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Text(text = "←", style = MaterialTheme.typography.titleLarge)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
) {
|
||||||
|
TerminalWebView(
|
||||||
|
pageUrl = pageUrl,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxSize(),
|
||||||
|
onReady = { controller = it },
|
||||||
|
onModifierConsumed = { name -> armed = armed - name },
|
||||||
|
)
|
||||||
|
if (keyboardOpen) {
|
||||||
|
TerminalKeyBar(
|
||||||
|
armed = armed,
|
||||||
|
onSend = { key ->
|
||||||
|
controller?.sendKey(key)
|
||||||
|
armed = emptySet()
|
||||||
|
},
|
||||||
|
onToggleModifier = { name ->
|
||||||
|
armed = if (name in armed) armed - name else armed + name
|
||||||
|
controller?.toggleModifier(name)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package pt.naps.maestro.ui.terminal
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebSettings
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/** Matches `#121419` in `assets/terminal/index.html` and the web SPA's own
|
||||||
|
* xterm theme, so the WebView doesn't flash white while the page loads. */
|
||||||
|
private const val TERMINAL_BACKGROUND_ARGB = 0xFF121419.toInt()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the terminal page loaded into a [TerminalWebView] from native code
|
||||||
|
* — the extra key bar's Tab/Esc/arrow buttons and Ctrl/Alt toggles. The page
|
||||||
|
* itself owns the `WS /api/attach/:id` socket and the xterm.js instance;
|
||||||
|
* this controller only calls the `maestro*` bridge functions it exposes.
|
||||||
|
*/
|
||||||
|
class TerminalWebViewController internal constructor(private val webView: WebView) {
|
||||||
|
fun sendKey(key: String) {
|
||||||
|
val arg = JSONObject.quote(key)
|
||||||
|
webView.evaluateJavascript("window.maestroKey && window.maestroKey($arg)", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleModifier(name: String) {
|
||||||
|
val arg = JSONObject.quote(name)
|
||||||
|
webView.evaluateJavascript("window.maestroToggleMod && window.maestroToggleMod($arg)", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun focus() {
|
||||||
|
webView.requestFocus()
|
||||||
|
webView.evaluateJavascript("window.maestroFocus && window.maestroFocus()", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminal pane composable for issue #266: a [WebView] loading the bundled
|
||||||
|
* xterm.js page (`assets/terminal/index.html`), which connects straight to
|
||||||
|
* the daemon's `WS /api/attach/:id` for this session. [onModifierConsumed]
|
||||||
|
* fires when the page's own JS folds an armed Ctrl/Alt into a key typed on
|
||||||
|
* the soft keyboard, so the native key bar can clear its toggle to match.
|
||||||
|
*/
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
@Composable
|
||||||
|
fun TerminalWebView(
|
||||||
|
pageUrl: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onReady: (TerminalWebViewController) -> Unit = {},
|
||||||
|
onModifierConsumed: (String) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val mainHandler = remember { Handler(Looper.getMainLooper()) }
|
||||||
|
AndroidView(
|
||||||
|
modifier = modifier,
|
||||||
|
factory = { context ->
|
||||||
|
WebView(context).apply {
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
)
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
settings.domStorageEnabled = true
|
||||||
|
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||||
|
setBackgroundColor(TERMINAL_BACKGROUND_ARGB)
|
||||||
|
addJavascriptInterface(
|
||||||
|
object {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onModConsumed(name: String) {
|
||||||
|
mainHandler.post { onModifierConsumed(name) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AndroidBridge",
|
||||||
|
)
|
||||||
|
onReady(TerminalWebViewController(this))
|
||||||
|
loadUrl(pageUrl)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
update = { webView ->
|
||||||
|
if (webView.url != pageUrl) webView.loadUrl(pageUrl)
|
||||||
|
},
|
||||||
|
onRelease = { it.destroy() },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package pt.naps.maestro.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
val Purple80 = Color(0xFFD0BCFF)
|
||||||
|
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||||
|
val Pink80 = Color(0xFFEFB8C8)
|
||||||
|
|
||||||
|
val Purple40 = Color(0xFF6650a4)
|
||||||
|
val PurpleGrey40 = Color(0xFF625b71)
|
||||||
|
val Pink40 = Color(0xFF7D5260)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package pt.naps.maestro.ui.theme
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
private val DarkColorScheme = darkColorScheme(
|
||||||
|
primary = Purple80,
|
||||||
|
secondary = PurpleGrey80,
|
||||||
|
tertiary = Pink80,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val LightColorScheme = lightColorScheme(
|
||||||
|
primary = Purple40,
|
||||||
|
secondary = PurpleGrey40,
|
||||||
|
tertiary = Pink40,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MaestroTheme(
|
||||||
|
darkTheme: Boolean = true,
|
||||||
|
dynamicColor: Boolean = false,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val useDarkTheme = darkTheme || isSystemInDarkTheme()
|
||||||
|
val colorScheme = when {
|
||||||
|
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
if (useDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||||
|
}
|
||||||
|
useDarkTheme -> DarkColorScheme
|
||||||
|
else -> LightColorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = Typography,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package pt.naps.maestro.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
val Typography = Typography(
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontFamily = FontFamily.Default,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.5.sp,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#1B1B1B"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#D0BCFF"
|
||||||
|
android:pathData="M54,24L74,84L64,84L54,52L44,84L34,84Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Maestro</string>
|
||||||
|
<string name="placeholder_message">Maestro is not connected to a daemon yet.</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<style name="Theme.Maestro" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:statusBarColor" tools:targetApi="21">@android:color/black</item>
|
||||||
|
<item name="android:windowBackground">@android:color/black</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package pt.naps.maestro
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ExampleUnitTest {
|
||||||
|
@Test
|
||||||
|
fun addition_isCorrect() {
|
||||||
|
assertEquals(4, 2 + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class BackoffTest {
|
||||||
|
@Test
|
||||||
|
fun `doubles from 500ms and caps at 10s`() {
|
||||||
|
val backoff = Backoff(initialMs = 500L, maxMs = 10_000L)
|
||||||
|
val delays = List(8) { backoff.nextDelayMs() }
|
||||||
|
assertEquals(listOf(500L, 1000L, 2000L, 4000L, 8000L, 10_000L, 10_000L, 10_000L), delays)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reset returns to the initial delay`() {
|
||||||
|
val backoff = Backoff(initialMs = 500L, maxMs = 10_000L)
|
||||||
|
repeat(4) { backoff.nextDelayMs() }
|
||||||
|
backoff.reset()
|
||||||
|
assertEquals(500L, backoff.nextDelayMs())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
import pt.naps.maestro.data.CatalogEvent
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SessionStatus
|
||||||
|
|
||||||
|
class CatalogStoreTest {
|
||||||
|
private fun session(id: String) = Session(
|
||||||
|
id = id,
|
||||||
|
cwd = "/tmp",
|
||||||
|
command = "bash",
|
||||||
|
status = SessionStatus.Running,
|
||||||
|
created_at = "2026-05-24T12:00:00Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `snapshot replaces the map keyed by id`() {
|
||||||
|
val store = CatalogStore()
|
||||||
|
store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b"))))
|
||||||
|
assertEquals(setOf("a", "b"), store.sessions.value.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `added and updated upsert one entry`() {
|
||||||
|
val store = CatalogStore()
|
||||||
|
store.apply(CatalogEvent.Snapshot(emptyList()))
|
||||||
|
store.apply(CatalogEvent.SessionAdded(session("a")))
|
||||||
|
assertEquals(1, store.sessions.value.size)
|
||||||
|
|
||||||
|
val renamed = session("a").copy(command = "zsh")
|
||||||
|
store.apply(CatalogEvent.SessionUpdated(renamed))
|
||||||
|
assertEquals("zsh", store.sessions.value.getValue("a").command)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removed drops the entry`() {
|
||||||
|
val store = CatalogStore()
|
||||||
|
store.apply(CatalogEvent.Snapshot(listOf(session("a"), session("b"))))
|
||||||
|
store.apply(CatalogEvent.SessionRemoved("a"))
|
||||||
|
assertEquals(setOf("b"), store.sessions.value.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `lagged reports the skip count without touching the map`() {
|
||||||
|
val store = CatalogStore()
|
||||||
|
store.apply(CatalogEvent.Snapshot(listOf(session("a"))))
|
||||||
|
val skipped = store.apply(CatalogEvent.Lagged(3))
|
||||||
|
assertEquals(3L, skipped)
|
||||||
|
assertEquals(setOf("a"), store.sessions.value.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-lagged events report no skip`() {
|
||||||
|
val store = CatalogStore()
|
||||||
|
assertNull(store.apply(CatalogEvent.Snapshot(emptyList())))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package pt.naps.maestro.connection
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import pt.naps.maestro.data.BuildInfo
|
||||||
|
import pt.naps.maestro.data.DaemonInfo
|
||||||
|
|
||||||
|
class DaemonIdentityTest {
|
||||||
|
private fun info(commit: String = "", instance: String = "") =
|
||||||
|
DaemonInfo(build = BuildInfo(commit = commit), instance = instance)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `same commit and instance is Same`() {
|
||||||
|
val a = info(commit = "abc123", instance = "i1")
|
||||||
|
assertEquals(RestartKind.SAME, compareIdentity(a, a.copy()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `different commit is Upgraded`() {
|
||||||
|
val before = info(commit = "abc123", instance = "i1")
|
||||||
|
val after = info(commit = "def456", instance = "i2")
|
||||||
|
assertEquals(RestartKind.UPGRADED, compareIdentity(before, after))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `same commit but different instance is Restarted`() {
|
||||||
|
val before = info(commit = "abc123", instance = "i1")
|
||||||
|
val after = info(commit = "abc123", instance = "i2")
|
||||||
|
assertEquals(RestartKind.RESTARTED, compareIdentity(before, after))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty commits on both sides never read as an upgrade`() {
|
||||||
|
val before = info(commit = "", instance = "i1")
|
||||||
|
val after = info(commit = "", instance = "i2")
|
||||||
|
assertEquals(RestartKind.RESTARTED, compareIdentity(before, after))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `format release names the commit and short date`() {
|
||||||
|
val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = false)
|
||||||
|
assertEquals("f89be4a716c7 · 2026-09-02", formatRelease(build))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dirty build is flagged`() {
|
||||||
|
val build = BuildInfo(commit = "f89be4a716c7", commit_date = "2026-09-02T10:00:00Z", dirty = true)
|
||||||
|
assertEquals("f89be4a716c7 · 2026-09-02 (dirty)", formatRelease(build))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `empty commit is unknown release`() {
|
||||||
|
assertEquals("unknown release", formatRelease(BuildInfo()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package pt.naps.maestro.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** Wire-format checks against fixtures mirrored from
|
||||||
|
* `crates/maestro-protocol/src/lib.rs`'s own test suite, so a Kotlin type
|
||||||
|
* that drifts from the Rust source of truth fails here first. */
|
||||||
|
class WireSerializationTest {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||||
|
|
||||||
|
private fun legacySessionJson(extra: String = "") = """
|
||||||
|
{"id":"00000000-0000-0000-0000-000000000001","cwd":"/tmp","command":"bash",
|
||||||
|
"status":"Running","created_at":"2026-05-24T12:00:00Z"$extra}
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `legacy session payload defaults new fields`() {
|
||||||
|
val s = json.decodeFromString<Session>(legacySessionJson())
|
||||||
|
assertEquals(ActivityState.Idle, s.activity)
|
||||||
|
assertEquals(SessionView.Terminal, s.view)
|
||||||
|
assertNull(s.foreground)
|
||||||
|
assertNull(s.blocked_work)
|
||||||
|
assertEquals(EPOCH, s.last_activity_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown activity state degrades to Idle`() {
|
||||||
|
val s = json.decodeFromString<Session>(legacySessionJson(""","activity":"QuantumEntangled""""))
|
||||||
|
assertEquals(ActivityState.Idle, s.activity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown name source degrades to User`() {
|
||||||
|
assertEquals(NameSource.User, json.decodeFromString<NameSource>("\"Rolled\""))
|
||||||
|
assertEquals(NameSource.Haiku, json.decodeFromString<NameSource>("\"Haiku\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `catalog event tags by type`() {
|
||||||
|
assertEquals(
|
||||||
|
"""{"type":"Lagged","skipped":3}""",
|
||||||
|
json.encodeToString<CatalogEvent>(CatalogEvent.Lagged(3)),
|
||||||
|
)
|
||||||
|
val removed = CatalogEvent.SessionRemoved("00000000-0000-0000-0000-000000000001")
|
||||||
|
assertEquals(removed, json.decodeFromString<CatalogEvent>(json.encodeToString<CatalogEvent>(removed)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `client control tags by type`() {
|
||||||
|
assertEquals(
|
||||||
|
"""{"type":"Resize","cols":120,"rows":40}""",
|
||||||
|
json.encodeToString<ClientControl>(ClientControl.Resize(120, 40)),
|
||||||
|
)
|
||||||
|
assertEquals(ClientControl.Ping, json.decodeFromString<ClientControl>("""{"type":"Ping"}"""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `structured approval decision is a bare string`() {
|
||||||
|
assertEquals(
|
||||||
|
StructuredApprovalDecision.AllowOnce,
|
||||||
|
json.decodeFromString<StructuredApprovalDecision>("\"allow_once\""),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"\"allow_for_session\"",
|
||||||
|
json.encodeToString(StructuredApprovalDecision.AllowForSession),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `structured event tags snake_case`() {
|
||||||
|
val event = json.decodeFromString<StructuredEvent>(
|
||||||
|
"""{"type":"transcript_snapshot","snapshot":{"cursor":7,"rows":[]}}""",
|
||||||
|
)
|
||||||
|
assertTrue(event is StructuredEvent.TranscriptSnapshotEvent)
|
||||||
|
assertEquals(7L, (event as StructuredEvent.TranscriptSnapshotEvent).snapshot.cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `transcript delta tags snake_case and round trips`() {
|
||||||
|
val row = TranscriptRow(
|
||||||
|
id = "tool:read-1",
|
||||||
|
kind = TranscriptRowKind.ToolCall,
|
||||||
|
data = json.parseToJsonElement("""{"name":"vendor.read"}"""),
|
||||||
|
)
|
||||||
|
val patch = TranscriptDelta.Patch(row)
|
||||||
|
val encoded = json.encodeToString<TranscriptDelta>(patch)
|
||||||
|
assertTrue(encoded.startsWith("""{"type":"patch","row":"""))
|
||||||
|
assertEquals(patch, json.decodeFromString<TranscriptDelta>(encoded))
|
||||||
|
assertEquals("\"agent_thought\"", json.encodeToString(TranscriptRowKind.AgentThought))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rename request encodes plainly`() {
|
||||||
|
assertEquals(
|
||||||
|
"""{"name":"amber-falcon"}""",
|
||||||
|
json.encodeToString(RenameSessionRequest("amber-falcon")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `api error decodes nested unsaved work with defaults`() {
|
||||||
|
val apiError = json.decodeFromString<ApiError>(
|
||||||
|
"""{"error":{"code":"unsaved_work","message":"nope","unsaved_work":{"uncommitted_files":3}}}""",
|
||||||
|
)
|
||||||
|
assertEquals(3, apiError.error.unsaved_work?.uncommitted_files)
|
||||||
|
assertEquals(0, apiError.error.unsaved_work?.unpushed_commits)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package pt.naps.maestro.ui.sidebar
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import pt.naps.maestro.data.InTheWay
|
||||||
|
import pt.naps.maestro.data.Session
|
||||||
|
import pt.naps.maestro.data.SessionStatus
|
||||||
|
import pt.naps.maestro.data.SessionWorktree
|
||||||
|
|
||||||
|
private fun session(
|
||||||
|
command: String = "claude",
|
||||||
|
cwd: String = "/home/user/project",
|
||||||
|
metadata: Map<String, JsonPrimitive> = emptyMap(),
|
||||||
|
worktree: SessionWorktree? = null,
|
||||||
|
blockedWork: InTheWay? = null,
|
||||||
|
activitySince: String = "1970-01-01T00:00:00Z",
|
||||||
|
) = Session(
|
||||||
|
id = "s1",
|
||||||
|
cwd = cwd,
|
||||||
|
command = command,
|
||||||
|
metadata = metadata,
|
||||||
|
worktree = worktree,
|
||||||
|
status = SessionStatus.Running,
|
||||||
|
created_at = "2026-01-01T00:00:00Z",
|
||||||
|
blocked_work = blockedWork,
|
||||||
|
activity_since = activitySince,
|
||||||
|
)
|
||||||
|
|
||||||
|
class DurationFormattingTest {
|
||||||
|
@Test
|
||||||
|
fun `floors seconds under a minute`() {
|
||||||
|
assertEquals("30s", activityDurationLabel(30_000))
|
||||||
|
assertEquals("59s", activityDurationLabel(59_999))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `floors minutes under an hour`() {
|
||||||
|
assertEquals("4m", activityDurationLabel(4 * 60_000 + 59_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `floors hours with no days unit`() {
|
||||||
|
assertEquals("2h", activityDurationLabel(2 * 3_600_000 + 59 * 60_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `held ms is null for missing or implausible since`() {
|
||||||
|
assertNull(activityHeldMs(null, 1_000))
|
||||||
|
assertNull(activityHeldMs("", 1_000))
|
||||||
|
assertNull(activityHeldMs("1970-01-01T00:00:00Z", 1_000))
|
||||||
|
assertNull(activityHeldMs("not-a-timestamp", 1_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `held ms is the gap to now for a plausible timestamp`() {
|
||||||
|
val since = "2026-01-01T00:00:00Z"
|
||||||
|
val nowMs = java.time.Instant.parse("2026-01-01T00:00:31Z").toEpochMilli()
|
||||||
|
assertEquals(31_000L, activityHeldMs(since, nowMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SessionNameTest {
|
||||||
|
@Test
|
||||||
|
fun `falls back to the command when no name is set`() {
|
||||||
|
assertEquals("claude --resume", sessionName(session(command = "claude --resume")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `uses the custom name when set`() {
|
||||||
|
val s = session(metadata = mapOf("name" to JsonPrimitive("api-work")))
|
||||||
|
assertEquals("api-work", sessionName(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty stored name is treated as unset`() {
|
||||||
|
val s = session(metadata = mapOf("name" to JsonPrimitive("")))
|
||||||
|
assertNull(customSessionName(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `generated name is provisional, user and haiku are not`() {
|
||||||
|
val generated = session(
|
||||||
|
metadata = mapOf(
|
||||||
|
"name" to JsonPrimitive("brave-otter"),
|
||||||
|
"name_source" to JsonPrimitive("Generated"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val user = session(
|
||||||
|
metadata = mapOf(
|
||||||
|
"name" to JsonPrimitive("api-work"),
|
||||||
|
"name_source" to JsonPrimitive("User"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val unknownSource = session(
|
||||||
|
metadata = mapOf(
|
||||||
|
"name" to JsonPrimitive("api-work"),
|
||||||
|
"name_source" to JsonPrimitive("something-new"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(isProvisionalName(generated))
|
||||||
|
assertFalse(isProvisionalName(user))
|
||||||
|
assertFalse(isProvisionalName(unknownSource))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BlockerStatsTest {
|
||||||
|
@Test
|
||||||
|
fun `renders insertions and deletions in diff notation`() {
|
||||||
|
val w = InTheWay(uncommitted_files = 2, untracked_files = 3, unpushed_commits = 2, insertions = 124, deletions = 18)
|
||||||
|
val stats = blockerStats(w).associate { it.kind to it.text }
|
||||||
|
assertEquals("+124", stats["add"])
|
||||||
|
assertEquals("-18", stats["del"])
|
||||||
|
assertEquals("?3", stats["untracked"])
|
||||||
|
assertEquals("↑2", stats["unpushed"])
|
||||||
|
assertNull(stats["files"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `falls back to a file count when a tracked change carries no line delta`() {
|
||||||
|
val w = InTheWay(uncommitted_files = 2, insertions = 0, deletions = 0)
|
||||||
|
val stats = blockerStats(w)
|
||||||
|
assertEquals(listOf(BlockerStat("files", "~2")), stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `session blockers are null when nothing blocks a kill`() {
|
||||||
|
assertNull(sessionBlockers(session(blockedWork = null)))
|
||||||
|
assertNull(sessionBlockers(session(blockedWork = InTheWay())))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `format in the way spells out zero as no unsaved work`() {
|
||||||
|
assertEquals("no unsaved work", formatInTheWay(InTheWay()))
|
||||||
|
assertEquals("1 uncommitted file, 2 untracked files", formatInTheWay(InTheWay(uncommitted_files = 1, untracked_files = 2)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProgramNameTest {
|
||||||
|
@Test
|
||||||
|
fun `drops arguments and basenames the path`() {
|
||||||
|
assertEquals("claude", programName("/usr/local/bin/claude -p hi"))
|
||||||
|
assertEquals("claude", programName("claude --dangerously-skip-permissions"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `looks through leading env assignments`() {
|
||||||
|
assertEquals("claude", programName("FOO=bar BAZ=qux claude --resume"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `looks through sudo and env wrappers with their own flags`() {
|
||||||
|
assertEquals("claude", programName("sudo claude"))
|
||||||
|
assertEquals("claude", programName("env -i FOO=bar claude"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `looks through js-toolchain launchers`() {
|
||||||
|
assertEquals("claude-code", programName("npx claude-code"))
|
||||||
|
assertEquals("claude", programName("pnpm dlx claude"))
|
||||||
|
assertEquals("claude", programName("bunx claude"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a command that is nothing but wrappers is shown as written`() {
|
||||||
|
assertEquals("sudo", programName("sudo"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GroupingTest {
|
||||||
|
@Test
|
||||||
|
fun `groups by worktree base_repo when present`() {
|
||||||
|
val a = session(cwd = "/wt/a", worktree = SessionWorktree(path = "/wt/a", base_repo = "/home/user/maestro"))
|
||||||
|
val b = session(cwd = "/wt/b", worktree = SessionWorktree(path = "/wt/b", base_repo = "/home/user/maestro"))
|
||||||
|
val groups = groupSessions(listOf(a, b))
|
||||||
|
assertEquals(1, groups.size)
|
||||||
|
assertEquals("maestro", groups.single().label)
|
||||||
|
assertEquals(2, groups.single().sessions.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `falls back to cwd with no worktree`() {
|
||||||
|
val a = session(cwd = "/home/user/scratch")
|
||||||
|
val groups = groupSessions(listOf(a))
|
||||||
|
assertEquals("scratch", groups.single().label)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package pt.naps.maestro.ui.spawn
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import pt.naps.maestro.data.SessionView
|
||||||
|
|
||||||
|
private val base = SpawnFormValues(
|
||||||
|
command = "claude",
|
||||||
|
cwd = "/home/user/project",
|
||||||
|
name = "amber-fox",
|
||||||
|
structuredView = false,
|
||||||
|
autoApprove = false,
|
||||||
|
worktreeEnabled = false,
|
||||||
|
worktreeBranch = "",
|
||||||
|
worktreeBranchMode = WorktreeBranchMode.New,
|
||||||
|
)
|
||||||
|
|
||||||
|
class SpawnModelTest {
|
||||||
|
@Test
|
||||||
|
fun `builds a plain request with the picked cwd and command`() {
|
||||||
|
val result = buildSpawnRequest(base, null) as SpawnBuildResult.Ok
|
||||||
|
assertEquals("/home/user/project", result.request.cwd)
|
||||||
|
assertEquals("claude", result.request.command)
|
||||||
|
assertEquals(mapOf("name" to JsonPrimitive("amber-fox")), result.request.metadata)
|
||||||
|
assertEquals(SessionView.Terminal, result.request.view)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `omits metadata name when the name is blank`() {
|
||||||
|
val result = buildSpawnRequest(base.copy(name = " "), null) as SpawnBuildResult.Ok
|
||||||
|
assertTrue(result.request.metadata.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `requests structured view for resolved claude and codex commands`() {
|
||||||
|
for (command in listOf("claude", "FOO=1 codex", "env -i codex")) {
|
||||||
|
val result = buildSpawnRequest(base.copy(command = command, structuredView = true), null) as SpawnBuildResult.Ok
|
||||||
|
assertEquals("expected structured view for `$command`", SessionView.Structured, result.request.view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `does not request structured view for unsupported commands`() {
|
||||||
|
val result = buildSpawnRequest(base.copy(command = "zsh", structuredView = true), null) as SpawnBuildResult.Ok
|
||||||
|
assertEquals(SessionView.Terminal, result.request.view)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `attaches a trimmed branch when worktree is enabled`() {
|
||||||
|
val result = buildSpawnRequest(
|
||||||
|
base.copy(worktreeEnabled = true, worktreeBranch = " feat/foo "),
|
||||||
|
"/home/user/project",
|
||||||
|
) as SpawnBuildResult.Ok
|
||||||
|
assertEquals("/home/user/project", result.request.worktree?.base_repo)
|
||||||
|
assertEquals("feat/foo", result.request.worktree?.branch)
|
||||||
|
assertTrue(result.request.worktree?.create_branch == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `checks out an existing branch when the mode is existing`() {
|
||||||
|
val result = buildSpawnRequest(
|
||||||
|
base.copy(worktreeEnabled = true, worktreeBranch = "spawn-v2", worktreeBranchMode = WorktreeBranchMode.Existing),
|
||||||
|
"/home/user/project",
|
||||||
|
) as SpawnBuildResult.Ok
|
||||||
|
assertEquals(false, result.request.worktree?.create_branch)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects an existing-mode worktree with no branch picked`() {
|
||||||
|
val result = buildSpawnRequest(
|
||||||
|
base.copy(worktreeEnabled = true, worktreeBranch = "", worktreeBranchMode = WorktreeBranchMode.Existing),
|
||||||
|
"/repo",
|
||||||
|
)
|
||||||
|
assertTrue(result is SpawnBuildResult.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects a missing working directory`() {
|
||||||
|
assertTrue(buildSpawnRequest(base.copy(cwd = ""), null) is SpawnBuildResult.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects a missing command`() {
|
||||||
|
assertTrue(buildSpawnRequest(base.copy(command = ""), null) is SpawnBuildResult.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects a worktree without an inferred base repo`() {
|
||||||
|
val result = buildSpawnRequest(base.copy(worktreeEnabled = true, worktreeBranch = "b"), null)
|
||||||
|
assertTrue(result is SpawnBuildResult.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `rejects a worktree without a branch name`() {
|
||||||
|
val result = buildSpawnRequest(base.copy(worktreeEnabled = true, worktreeBranch = " "), "/repo")
|
||||||
|
assertTrue(result is SpawnBuildResult.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolveProgram strips env assignments and paths`() {
|
||||||
|
assertEquals("codex", resolveProgram("FOO=1 /usr/local/bin/codex --flag"))
|
||||||
|
assertEquals("claude", resolveProgram("claude"))
|
||||||
|
assertEquals("zsh", resolveProgram("zsh -l"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
package pt.naps.maestro.ui.spawn
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import java.util.concurrent.ConcurrentLinkedQueue
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.mockwebserver.Dispatcher
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import okhttp3.mockwebserver.RecordedRequest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import pt.naps.maestro.api.MaestroClient
|
||||||
|
import pt.naps.maestro.data.Project
|
||||||
|
|
||||||
|
/** Routes each enqueued response by request path rather than arrival order —
|
||||||
|
* [SpawnViewModel]'s init fires `/api/info` and `/api/projects` concurrently,
|
||||||
|
* so a plain FIFO queue can hand either endpoint the other's response. */
|
||||||
|
private class RoutedDispatcher : Dispatcher() {
|
||||||
|
private val queues = ConcurrentHashMap<String, ConcurrentLinkedQueue<MockResponse>>()
|
||||||
|
|
||||||
|
fun enqueue(path: String, response: MockResponse) {
|
||||||
|
queues.getOrPut(path) { ConcurrentLinkedQueue() }.add(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||||
|
val path = request.path.orEmpty().substringBefore('?')
|
||||||
|
return queues[path]?.poll() ?: MockResponse().setResponseCode(404)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SpawnViewModelTest {
|
||||||
|
private lateinit var server: MockWebServer
|
||||||
|
private lateinit var dispatcher: RoutedDispatcher
|
||||||
|
private lateinit var client: MaestroClient
|
||||||
|
private lateinit var scope: CoroutineScope
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
dispatcher = RoutedDispatcher()
|
||||||
|
server = MockWebServer()
|
||||||
|
server.dispatcher = dispatcher
|
||||||
|
server.start()
|
||||||
|
client = MaestroClient(server.url("/").toString())
|
||||||
|
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
scope.cancel()
|
||||||
|
server.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonResponse(body: String) =
|
||||||
|
MockResponse().setHeader("Content-Type", "application/json").setBody(body)
|
||||||
|
|
||||||
|
/** Enqueues a passing `/api/info` and an empty `/api/projects`, the pair
|
||||||
|
* every test needs before it can observe anything past init. */
|
||||||
|
private fun enqueueInit(infoBody: String = """{"hostname":"h","version":"1","home_dir":"","presets":[],"instance":"i"}""") {
|
||||||
|
dispatcher.enqueue("/api/info", jsonResponse(infoBody))
|
||||||
|
dispatcher.enqueue("/api/projects", jsonResponse("[]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newViewModel() = SpawnViewModel(client, scope)
|
||||||
|
|
||||||
|
private suspend fun SpawnViewModel.awaitState(predicate: (SpawnUiState) -> Boolean): SpawnUiState {
|
||||||
|
var result: SpawnUiState? = null
|
||||||
|
state.test {
|
||||||
|
var item = awaitItem()
|
||||||
|
while (!predicate(item)) item = awaitItem()
|
||||||
|
result = item
|
||||||
|
cancelAndIgnoreRemainingEvents()
|
||||||
|
}
|
||||||
|
return result ?: error("state predicate never satisfied")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `loads presets from daemon info and defaults to the first one`() = runBlocking {
|
||||||
|
enqueueInit(
|
||||||
|
"""{"hostname":"h","version":"1","home_dir":"/home/x","presets":[
|
||||||
|
{"id":"claude","label":"Claude","command":"claude"},
|
||||||
|
{"id":"custom","label":"Custom…","command":""}
|
||||||
|
],"instance":"i"}""",
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = newViewModel()
|
||||||
|
val settled = vm.awaitState { it.selectedPresetId == "claude" }
|
||||||
|
|
||||||
|
assertEquals("claude", settled.command)
|
||||||
|
assertTrue("structured view should auto-tick for claude", settled.structuredView)
|
||||||
|
assertEquals("/home/x", settled.homeDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `falls back to built-in presets when daemon info fails`() = runBlocking {
|
||||||
|
dispatcher.enqueue("/api/info", MockResponse().setResponseCode(500))
|
||||||
|
dispatcher.enqueue("/api/projects", jsonResponse("[]"))
|
||||||
|
|
||||||
|
val vm = newViewModel()
|
||||||
|
val settled = vm.awaitState { it.selectedPresetId.isNotEmpty() }
|
||||||
|
|
||||||
|
assertEquals(FALLBACK_PRESETS, settled.presets)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selecting a preset updates the command and structured view`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { it.selectedPresetId.isNotEmpty() } // let init settle on "claude"
|
||||||
|
|
||||||
|
vm.selectPreset("zsh")
|
||||||
|
val afterZsh = vm.awaitState { it.selectedPresetId == "zsh" }
|
||||||
|
assertEquals("zsh", afterZsh.command)
|
||||||
|
assertTrue("zsh has no structured view", !afterZsh.structuredView)
|
||||||
|
|
||||||
|
vm.selectPreset("claude")
|
||||||
|
val afterClaude = vm.awaitState { it.selectedPresetId == "claude" }
|
||||||
|
assertTrue(afterClaude.structuredView)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `manual structured view toggle survives a later preset change`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { it.selectedPresetId == "claude" }
|
||||||
|
|
||||||
|
vm.setStructuredView(false)
|
||||||
|
vm.selectPreset("codex")
|
||||||
|
val after = vm.awaitState { it.selectedPresetId == "codex" }
|
||||||
|
assertTrue("operator's untick should stick", !after.structuredView)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selecting a project fills cwd and base repo`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { !it.projectsLoading }
|
||||||
|
|
||||||
|
vm.selectProject(Project(path = "/repo", name = "repo"))
|
||||||
|
val after = vm.awaitState { it.cwd == "/repo" }
|
||||||
|
assertEquals("/repo", after.baseRepo)
|
||||||
|
assertEquals("/repo", after.selectedProjectPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `typing a raw path debounces dir suggestions and base repo`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { !it.projectsLoading }
|
||||||
|
|
||||||
|
dispatcher.enqueue("/api/dirs", jsonResponse("""{"path":"/home/user","entries":["proj-a","proj-b"]}"""))
|
||||||
|
dispatcher.enqueue("/api/gitroot", jsonResponse("""{"root":"/home/user/proj-a"}"""))
|
||||||
|
|
||||||
|
vm.setCwd("/home/user/pro")
|
||||||
|
val settled = vm.awaitState { it.dirSuggestions.isNotEmpty() }
|
||||||
|
assertEquals(listOf("proj-a", "proj-b"), settled.dirSuggestions)
|
||||||
|
val withRepo = vm.awaitState { it.baseRepo != null }
|
||||||
|
assertEquals("/home/user/proj-a", withRepo.baseRepo)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reroll fills the name field from the daemon`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { !it.projectsLoading }
|
||||||
|
|
||||||
|
dispatcher.enqueue("/api/names", jsonResponse("""{"names":["amber-fox"]}"""))
|
||||||
|
vm.rerollName()
|
||||||
|
val after = vm.awaitState { it.name == "amber-fox" }
|
||||||
|
assertEquals("amber-fox", after.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `submit rejects a missing working directory without a network call`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { it.selectedPresetId == "claude" }
|
||||||
|
|
||||||
|
var succeeded = false
|
||||||
|
vm.submit { succeeded = true }
|
||||||
|
val after = vm.awaitState { it.error != null }
|
||||||
|
assertNotNull(after.error)
|
||||||
|
assertTrue(!succeeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `submit posts the built request and reports the new session`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { it.selectedPresetId == "claude" }
|
||||||
|
vm.selectProject(Project(path = "/repo", name = "repo"))
|
||||||
|
vm.awaitState { it.cwd == "/repo" }
|
||||||
|
|
||||||
|
dispatcher.enqueue(
|
||||||
|
"/api/sessions",
|
||||||
|
jsonResponse(
|
||||||
|
"""{"id":"s1","cwd":"/repo","command":"claude","status":"Running","created_at":"2026-01-01T00:00:00Z"}""",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
var result: pt.naps.maestro.data.Session? = null
|
||||||
|
vm.submit { result = it }
|
||||||
|
val after = vm.awaitState { !it.submitting && it.error == null }
|
||||||
|
assertNull(after.error)
|
||||||
|
assertEquals("s1", result?.id)
|
||||||
|
|
||||||
|
var request = server.takeRequest()
|
||||||
|
while (request.method != "POST") request = server.takeRequest()
|
||||||
|
assertEquals("/api/sessions", request.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `submit surfaces the daemon's error detail on failure`() = runBlocking {
|
||||||
|
enqueueInit()
|
||||||
|
val vm = newViewModel()
|
||||||
|
vm.awaitState { it.selectedPresetId == "claude" }
|
||||||
|
vm.selectProject(Project(path = "/repo", name = "repo"))
|
||||||
|
vm.awaitState { it.cwd == "/repo" }
|
||||||
|
|
||||||
|
dispatcher.enqueue(
|
||||||
|
"/api/sessions",
|
||||||
|
MockResponse().setResponseCode(400).setHeader("Content-Type", "application/json")
|
||||||
|
.setBody("""{"error":{"code":"bad_request","message":"cwd does not exist"}}"""),
|
||||||
|
)
|
||||||
|
vm.submit {}
|
||||||
|
val after = vm.awaitState { it.error != null }
|
||||||
|
assertTrue(after.error!!.contains("cwd does not exist"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package pt.naps.maestro.ui.terminal
|
||||||
|
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class TerminalKeyBarActionsTest {
|
||||||
|
@Test
|
||||||
|
fun `contains tab esc and all four arrows`() {
|
||||||
|
val labels = TERMINAL_KEY_ACTIONS.map { it.label }
|
||||||
|
assertTrue(labels.containsAll(listOf("Tab", "Esc", "↑", "↓", "←", "→")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ctrl and alt are modifiers rather than sends`() {
|
||||||
|
val modifierNames = TERMINAL_KEY_ACTIONS.filterIsInstance<TerminalKeyAction.Modifier>().map { it.name }
|
||||||
|
assertTrue(modifierNames.containsAll(listOf("ctrl", "alt")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `arrow keys carry the maestroKey names the terminal page expects`() {
|
||||||
|
val sendKeys = TERMINAL_KEY_ACTIONS.filterIsInstance<TerminalKeyAction.Send>().map { it.key }
|
||||||
|
assertTrue(sendKeys.containsAll(listOf("Up", "Down", "Left", "Right", "Tab", "Esc")))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package pt.naps.maestro.ui.terminal
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class TerminalPageUrlTest {
|
||||||
|
@Test
|
||||||
|
fun `builds asset url with the encoded attach ws url`() {
|
||||||
|
val url = TerminalPageUrl.build("http://192.168.1.5:7070", "abc-123")
|
||||||
|
assertEquals(
|
||||||
|
"file:///android_asset/terminal/index.html" +
|
||||||
|
"?ws=ws%3A%2F%2F192.168.1.5%3A7070%2Fapi%2Fattach%2Fabc-123",
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `swaps an https base to wss`() {
|
||||||
|
val url = TerminalPageUrl.build("https://daemon.example.com", "s1")
|
||||||
|
assertTrue(url.contains("wss%3A%2F%2Fdaemon.example.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `strips a trailing slash from the daemon base url`() {
|
||||||
|
val url = TerminalPageUrl.build("http://host:7070/", "s1")
|
||||||
|
assertTrue(url.contains("ws%3A%2F%2Fhost%3A7070%2Fapi%2Fattach%2Fs1"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=true
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "8.5.2"
|
||||||
|
kotlin = "2.0.21"
|
||||||
|
coreKtx = "1.13.1"
|
||||||
|
lifecycleRuntimeKtx = "2.8.6"
|
||||||
|
activityCompose = "1.9.2"
|
||||||
|
composeBom = "2024.09.03"
|
||||||
|
navigationCompose = "2.8.2"
|
||||||
|
okhttp = "4.12.0"
|
||||||
|
kotlinxSerializationJson = "1.7.3"
|
||||||
|
kotlinxCoroutines = "1.9.0"
|
||||||
|
turbine = "1.1.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
|
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
|
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||||
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||||
|
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
|
||||||
|
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" }
|
||||||
|
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
|
||||||
|
junit = { group = "junit", name = "junit", version = "4.13.2" }
|
||||||
|
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version = "1.2.1" }
|
||||||
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version = "3.6.1" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
+237
@@ -0,0 +1,237 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# issues, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated via the Gradle "wrapper" task
|
||||||
|
# (see https://docs.gradle.org/current/userguide/gradle_wrapper.html)
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="$APP_HOME/gradle/wrapper/gradle-wrapper.jar"
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command;
|
||||||
|
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments that
|
||||||
|
# shell script including quotes and variable substitutions, so put them in
|
||||||
|
# double quotes to make sure that they get re-expanded; and
|
||||||
|
# * put everything else in single quotes, so that it's not re-expanded.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-A-Za-z0-9_+.]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
Vendored
+89
@@ -0,0 +1,89 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" -Dorg.gradle.appname=%APP_BASE_NAME% -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "Maestro"
|
||||||
|
|
||||||
|
include(":app")
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "maestro-acp"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
description = "ACP client role: one provider adapter child per structured session"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
agent-client-protocol.workspace = true
|
||||||
|
libc.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tokio-util.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
Executable
+262
@@ -0,0 +1,262 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""A deterministic stand-in for `claude-code-acp` (#134).
|
||||||
|
|
||||||
|
Speaks just enough JSON-RPC over stdio for the daemon's handshake:
|
||||||
|
answers `initialize` and `session/new`, logs every method it receives, and
|
||||||
|
exits on SIGTERM. Tests point `PATH` at a directory holding this file under
|
||||||
|
the adapter's name, so the real adapter (which needs Claude auth and is
|
||||||
|
slow) never runs in the deterministic suite.
|
||||||
|
|
||||||
|
Environment knobs, all optional:
|
||||||
|
|
||||||
|
FAKE_ACP_LOG append one line per event to this file:
|
||||||
|
start pid=<pid> cwd=<cwd>
|
||||||
|
env <NAME>=<value> (for FAKE_ACP_ECHO_ENV)
|
||||||
|
caps <json of clientCapabilities>
|
||||||
|
recv <method>
|
||||||
|
signal SIGTERM
|
||||||
|
FAKE_ACP_ECHO_ENV name of one env var to log at start
|
||||||
|
FAKE_ACP_INIT_FAIL=1 answer `initialize` with a JSON-RPC error
|
||||||
|
FAKE_ACP_DELAY_SESSION_NEW_MS=n wait n milliseconds before answering
|
||||||
|
`session/new`
|
||||||
|
FAKE_ACP_SESSION_NEW_GATE=path wait for `path` to exist before answering
|
||||||
|
`session/new`
|
||||||
|
FAKE_ACP_EXIT_AFTER_NEW=1 exit 0 right after answering `session/new`
|
||||||
|
FAKE_ACP_EXIT_ON_CANCEL=1 exit 0 on `session/cancel` (default: keep running
|
||||||
|
so the SIGTERM path is exercised)
|
||||||
|
FAKE_ACP_IGNORE_EOF=1 keep running after stdin closes, until signalled
|
||||||
|
FAKE_ACP_EMIT_ON_CANCEL=1 emit a final notification before handling cancel
|
||||||
|
FAKE_ACP_CANCEL_SETTLES=1 complete the outstanding prompt on cancel
|
||||||
|
FAKE_ACP_NO_LOAD=1 advertise `loadSession: false`
|
||||||
|
FAKE_ACP_LOAD_REPLAY=n on `session/load`, replay n `agent_message_chunk`
|
||||||
|
notifications ("replayed <i>") before answering,
|
||||||
|
the way a real agent streams history back
|
||||||
|
FAKE_ACP_LOAD_FAIL=1 answer `session/load` with a JSON-RPC error
|
||||||
|
FAKE_ACP_DELAY_PROMPT_MS=n wait before answering `session/prompt`
|
||||||
|
FAKE_ACP_PROMPT_FAIL=1 answer every `session/prompt` with a JSON-RPC error
|
||||||
|
FAKE_ACP_PROMPT_FAIL_ONCE=1 answer only the first `session/prompt` with a
|
||||||
|
JSON-RPC error
|
||||||
|
FAKE_ACP_SETTINGS=1 advertise legacy model/mode and modern effort options
|
||||||
|
FAKE_ACP_PERMISSION_AFTER_CANCEL=1 defer the first prompt's permission
|
||||||
|
callback until a later prompt
|
||||||
|
|
||||||
|
Signals: SIGUSR1 emits one `agent_message_chunk` notification ("tick <n>",
|
||||||
|
counting from 1) and logs `emit tick <n>`, so a test can make the agent
|
||||||
|
speak on demand (#135).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
LOG = os.environ.get("FAKE_ACP_LOG")
|
||||||
|
|
||||||
|
|
||||||
|
def log(line):
|
||||||
|
if not LOG:
|
||||||
|
return
|
||||||
|
with open(LOG, "a") as f:
|
||||||
|
f.write(line + "\n")
|
||||||
|
f.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def send(obj):
|
||||||
|
sys.stdout.write(json.dumps(obj) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def on_sigterm(_signum, _frame):
|
||||||
|
log("signal SIGTERM")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, on_sigterm)
|
||||||
|
|
||||||
|
session_id = f"fake-{os.getpid()}"
|
||||||
|
ticks = 0
|
||||||
|
prompt_failures = 0
|
||||||
|
pending_prompt_id = None
|
||||||
|
pending_permission_id = None
|
||||||
|
delayed_permission = False
|
||||||
|
current_model = "opus"
|
||||||
|
current_mode = "default"
|
||||||
|
current_effort = "high"
|
||||||
|
|
||||||
|
|
||||||
|
def settings_response(include_session=False):
|
||||||
|
result = {}
|
||||||
|
if include_session:
|
||||||
|
result["sessionId"] = session_id
|
||||||
|
if os.environ.get("FAKE_ACP_SETTINGS") == "1":
|
||||||
|
result.update({
|
||||||
|
"models": {
|
||||||
|
"currentModelId": current_model,
|
||||||
|
"availableModels": [
|
||||||
|
{"modelId": "opus", "name": "Opus"},
|
||||||
|
{"modelId": "sonnet", "name": "Sonnet"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"modes": {
|
||||||
|
"currentModeId": current_mode,
|
||||||
|
"availableModes": [
|
||||||
|
{"id": "default", "name": "Default"},
|
||||||
|
{"id": "plan", "name": "Plan"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"configOptions": [{
|
||||||
|
"id": "effort", "name": "Effort", "category": "thought_level",
|
||||||
|
"type": "select", "currentValue": current_effort,
|
||||||
|
"options": [
|
||||||
|
{"value": "low", "name": "Low"},
|
||||||
|
{"value": "high", "name": "High"},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def chunk(text):
|
||||||
|
return {"jsonrpc": "2.0", "method": "session/update", "params": {
|
||||||
|
"sessionId": session_id,
|
||||||
|
"update": {"sessionUpdate": "agent_message_chunk",
|
||||||
|
"content": {"type": "text", "text": text}},
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
|
def on_sigusr1(_signum, _frame):
|
||||||
|
global ticks
|
||||||
|
ticks += 1
|
||||||
|
send(chunk(f"tick {ticks}"))
|
||||||
|
log(f"emit tick {ticks}")
|
||||||
|
|
||||||
|
|
||||||
|
signal.signal(signal.SIGUSR1, on_sigusr1)
|
||||||
|
|
||||||
|
log(f"start pid={os.getpid()} cwd={os.getcwd()}")
|
||||||
|
echo = os.environ.get("FAKE_ACP_ECHO_ENV")
|
||||||
|
if echo:
|
||||||
|
log(f"env {echo}={os.environ.get(echo, '')}")
|
||||||
|
|
||||||
|
for raw in sys.stdin:
|
||||||
|
raw = raw.strip()
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
msg = json.loads(raw)
|
||||||
|
method = msg.get("method")
|
||||||
|
if method is None:
|
||||||
|
# A response to something we sent; nothing to do.
|
||||||
|
if msg.get("id") == pending_permission_id:
|
||||||
|
log("permission response " + json.dumps(msg.get("result"), sort_keys=True))
|
||||||
|
send({"jsonrpc": "2.0", "id": pending_prompt_id,
|
||||||
|
"result": {"stopReason": "end_turn"}})
|
||||||
|
pending_prompt_id = None
|
||||||
|
pending_permission_id = None
|
||||||
|
continue
|
||||||
|
log(f"recv {method}")
|
||||||
|
rid = msg.get("id")
|
||||||
|
params = msg.get("params") or {}
|
||||||
|
|
||||||
|
if method == "initialize":
|
||||||
|
log("caps " + json.dumps(params.get("clientCapabilities", {}), sort_keys=True))
|
||||||
|
if os.environ.get("FAKE_ACP_INIT_FAIL") == "1":
|
||||||
|
send({"jsonrpc": "2.0", "id": rid,
|
||||||
|
"error": {"code": -32000, "message": "fake init failure"}})
|
||||||
|
continue
|
||||||
|
send({"jsonrpc": "2.0", "id": rid, "result": {
|
||||||
|
"protocolVersion": params.get("protocolVersion", 1),
|
||||||
|
"agentCapabilities": {
|
||||||
|
"loadSession": os.environ.get("FAKE_ACP_NO_LOAD") != "1"},
|
||||||
|
"agentInfo": {"name": "fake-acp-agent", "version": "0.0.0"},
|
||||||
|
"authMethods": [],
|
||||||
|
}})
|
||||||
|
elif method == "session/new":
|
||||||
|
gate = os.environ.get("FAKE_ACP_SESSION_NEW_GATE")
|
||||||
|
while gate and not os.path.exists(gate):
|
||||||
|
time.sleep(0.01)
|
||||||
|
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_SESSION_NEW_MS", "0")) / 1000)
|
||||||
|
send({"jsonrpc": "2.0", "id": rid, "result": settings_response(True)})
|
||||||
|
send(chunk("hello from fake"))
|
||||||
|
if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1":
|
||||||
|
sys.exit(0)
|
||||||
|
elif method == "session/load":
|
||||||
|
session_id = params.get("sessionId")
|
||||||
|
log(f"recv session/load sessionId={session_id}")
|
||||||
|
if os.environ.get("FAKE_ACP_LOAD_FAIL") == "1":
|
||||||
|
send({"jsonrpc": "2.0", "id": rid,
|
||||||
|
"error": {"code": -32000, "message": "fake load failure"}})
|
||||||
|
continue
|
||||||
|
for i in range(int(os.environ.get("FAKE_ACP_LOAD_REPLAY", "0"))):
|
||||||
|
send(chunk(f"replayed {i + 1}"))
|
||||||
|
send({"jsonrpc": "2.0", "id": rid, "result": settings_response()})
|
||||||
|
send(chunk("hello after load"))
|
||||||
|
elif method == "session/set_model":
|
||||||
|
current_model = params.get("modelId")
|
||||||
|
send({"jsonrpc": "2.0", "id": rid, "result": {}})
|
||||||
|
elif method == "session/set_mode":
|
||||||
|
current_mode = params.get("modeId")
|
||||||
|
send({"jsonrpc": "2.0", "id": rid, "result": {}})
|
||||||
|
elif method == "session/set_config_option":
|
||||||
|
current_effort = params.get("value")
|
||||||
|
send({"jsonrpc": "2.0", "id": rid, "result": {
|
||||||
|
"configOptions": settings_response()["configOptions"]}})
|
||||||
|
elif method == "session/cancel":
|
||||||
|
if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1":
|
||||||
|
send(chunk("final on cancel"))
|
||||||
|
if (os.environ.get("FAKE_ACP_CANCEL_SETTLES") == "1"
|
||||||
|
and pending_prompt_id is not None):
|
||||||
|
send({"jsonrpc": "2.0", "id": pending_prompt_id,
|
||||||
|
"result": {"stopReason": "cancelled"}})
|
||||||
|
pending_prompt_id = None
|
||||||
|
pending_permission_id = None
|
||||||
|
if os.environ.get("FAKE_ACP_EXIT_ON_CANCEL") == "1":
|
||||||
|
sys.exit(0)
|
||||||
|
elif method == "session/prompt":
|
||||||
|
if os.environ.get("FAKE_ACP_PERMISSION_AFTER_CANCEL") == "1":
|
||||||
|
if pending_prompt_id is None:
|
||||||
|
pending_prompt_id = rid
|
||||||
|
delayed_permission = True
|
||||||
|
continue
|
||||||
|
if delayed_permission:
|
||||||
|
delayed_permission = False
|
||||||
|
pending_permission_id = "permission-1"
|
||||||
|
send({"jsonrpc": "2.0", "id": pending_permission_id,
|
||||||
|
"method": "session/request_permission", "params": {
|
||||||
|
"sessionId": session_id,
|
||||||
|
"toolCall": {"toolCallId": "tool-1", "fields": {}},
|
||||||
|
"options": [
|
||||||
|
{"optionId": "once", "name": "Allow once", "kind": "allow_once"},
|
||||||
|
{"optionId": "deny", "name": "Deny", "kind": "reject_once"}
|
||||||
|
]
|
||||||
|
}})
|
||||||
|
continue
|
||||||
|
if os.environ.get("FAKE_ACP_REQUEST_PERMISSION") == "1":
|
||||||
|
pending_prompt_id = rid
|
||||||
|
pending_permission_id = "permission-1"
|
||||||
|
send({"jsonrpc": "2.0", "id": pending_permission_id,
|
||||||
|
"method": "session/request_permission", "params": {
|
||||||
|
"sessionId": session_id,
|
||||||
|
"toolCall": {"toolCallId": "tool-1", "fields": {}},
|
||||||
|
"options": [
|
||||||
|
{"optionId": "once", "name": "Allow once", "kind": "allow_once"},
|
||||||
|
{"optionId": "session", "name": "Allow session", "kind": "allow_always"},
|
||||||
|
{"optionId": "deny", "name": "Deny", "kind": "reject_once"}
|
||||||
|
]
|
||||||
|
}})
|
||||||
|
continue
|
||||||
|
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_PROMPT_MS", "0")) / 1000)
|
||||||
|
fail_once = os.environ.get("FAKE_ACP_PROMPT_FAIL_ONCE") == "1"
|
||||||
|
if os.environ.get("FAKE_ACP_PROMPT_FAIL") == "1" or (fail_once and prompt_failures == 0):
|
||||||
|
prompt_failures += 1
|
||||||
|
send({"jsonrpc": "2.0", "id": rid,
|
||||||
|
"error": {"code": -32000, "message": "fake prompt failure"}})
|
||||||
|
else:
|
||||||
|
send({"jsonrpc": "2.0", "id": rid,
|
||||||
|
"result": {"stopReason": "end_turn"}})
|
||||||
|
elif rid is not None:
|
||||||
|
send({"jsonrpc": "2.0", "id": rid,
|
||||||
|
"error": {"code": -32601, "message": f"method not found: {method}"}})
|
||||||
|
|
||||||
|
if os.environ.get("FAKE_ACP_IGNORE_EOF") == "1":
|
||||||
|
while True:
|
||||||
|
signal.pause()
|
||||||
@@ -0,0 +1,514 @@
|
|||||||
|
//! `maestro-acp` against the fake adapter in `tests/fake_acp_agent.py`, plus
|
||||||
|
//! ignored smoke tests against each maintained adapter.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use maestro_acp::schema::{ContentBlock, SessionUpdate};
|
||||||
|
use maestro_acp::{AcpAdapter, AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn fake_agent() -> PathBuf {
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fake_acp_agent.py")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A temp `bin/` holding the fake agent under the adapter's name, plus a
|
||||||
|
/// log file the agent appends to. `PATH` for the spawn is just that dir.
|
||||||
|
struct Fake {
|
||||||
|
dir: TempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fake {
|
||||||
|
fn new() -> Self {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let bin = dir.path().join("bin");
|
||||||
|
std::fs::create_dir_all(&bin).unwrap();
|
||||||
|
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
|
||||||
|
std::fs::copy(fake_agent(), bin.join(AcpAdapter::Codex.binary())).unwrap();
|
||||||
|
Self { dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_path(&self) -> PathBuf {
|
||||||
|
self.dir.path().join("agent.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log(&self) -> String {
|
||||||
|
std::fs::read_to_string(self.log_path()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fake's `bin/` first, so it shadows any real adapter, then the
|
||||||
|
/// process `PATH` so the script's `python3` still resolves.
|
||||||
|
fn env(&self) -> HashMap<String, String> {
|
||||||
|
let path = format!(
|
||||||
|
"{}:{}",
|
||||||
|
self.dir.path().join("bin").display(),
|
||||||
|
std::env::var("PATH").unwrap_or_default()
|
||||||
|
);
|
||||||
|
HashMap::from([
|
||||||
|
("PATH".to_string(), path),
|
||||||
|
(
|
||||||
|
"FAKE_ACP_LOG".to_string(),
|
||||||
|
self.log_path().to_string_lossy().into_owned(),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config(&self, cwd: &Path) -> SpawnConfig {
|
||||||
|
let mut config = SpawnConfig::new(cwd, self.env());
|
||||||
|
config.handshake_timeout = Duration::from_secs(10);
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_log(&self, needle: &str) {
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if self.log().contains(needle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
}
|
||||||
|
panic!("log never contained {needle:?}:\n{}", self.log());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pid(&self) -> u32 {
|
||||||
|
self.log()
|
||||||
|
.lines()
|
||||||
|
.find_map(|line| line.strip_prefix("start pid="))
|
||||||
|
.and_then(|rest| rest.split(' ').next())
|
||||||
|
.and_then(|pid| pid.parse().ok())
|
||||||
|
.expect("pid in log")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_exit(&self) {
|
||||||
|
let pid = self.pid();
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
while pid_alive(pid) && Instant::now() < deadline {
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
}
|
||||||
|
assert!(!pid_alive(pid), "child {pid} still alive");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pid_alive(pid: u32) -> bool {
|
||||||
|
Path::new(&format!("/proc/{pid}")).exists()
|
||||||
|
&& std::fs::read_to_string(format!("/proc/{pid}/stat"))
|
||||||
|
.map(|s| !s.contains(") Z "))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn spawn_runs_adapter_in_cwd_with_env_and_restrictive_caps() {
|
||||||
|
let fake = Fake::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let mut config = fake.config(cwd.path());
|
||||||
|
config
|
||||||
|
.env
|
||||||
|
.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
|
||||||
|
config
|
||||||
|
.env
|
||||||
|
.insert("MAESTRO_SESSION_ID".into(), "sess-123".into());
|
||||||
|
|
||||||
|
let session = maestro_acp::spawn(config).await.expect("spawn");
|
||||||
|
assert!(session.session_id().starts_with("fake-"));
|
||||||
|
assert!(pid_alive(session.pid()));
|
||||||
|
|
||||||
|
let log = fake.log();
|
||||||
|
let cwd_canon = cwd.path().canonicalize().unwrap();
|
||||||
|
assert!(
|
||||||
|
log.contains(&format!("cwd={}", cwd_canon.display())),
|
||||||
|
"child cwd missing from log:\n{log}"
|
||||||
|
);
|
||||||
|
assert!(log.contains("env MAESTRO_SESSION_ID=sess-123"), "{log}");
|
||||||
|
assert!(log.contains("recv initialize"), "{log}");
|
||||||
|
assert!(log.contains("recv session/new"), "{log}");
|
||||||
|
let caps = log
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| l.strip_prefix("caps "))
|
||||||
|
.expect("caps line");
|
||||||
|
let caps: serde_json::Value = serde_json::from_str(caps).unwrap();
|
||||||
|
assert_eq!(caps["fs"]["readTextFile"], false);
|
||||||
|
assert_eq!(caps["fs"]["writeTextFile"], false);
|
||||||
|
assert_eq!(caps["terminal"], false);
|
||||||
|
|
||||||
|
session
|
||||||
|
.terminate(Duration::from_millis(100), Duration::from_secs(2))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn missing_adapter_names_the_install_command() {
|
||||||
|
let empty = TempDir::new().unwrap();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let env = HashMap::from([(
|
||||||
|
"PATH".to_string(),
|
||||||
|
empty.path().to_string_lossy().into_owned(),
|
||||||
|
)]);
|
||||||
|
let err = maestro_acp::spawn(SpawnConfig::new(cwd.path(), env))
|
||||||
|
.await
|
||||||
|
.expect_err("no adapter on PATH");
|
||||||
|
assert!(matches!(
|
||||||
|
err,
|
||||||
|
AcpError::AdapterMissing {
|
||||||
|
adapter: AcpAdapter::ClaudeCode
|
||||||
|
}
|
||||||
|
));
|
||||||
|
let msg = err.to_string();
|
||||||
|
assert!(msg.contains(ADAPTER_BIN), "{msg}");
|
||||||
|
assert!(msg.contains(INSTALL_COMMAND), "{msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codex_adapter_has_its_own_binary_and_install_guidance() {
|
||||||
|
assert_eq!(AcpAdapter::Codex.binary(), "codex-acp");
|
||||||
|
assert!(AcpAdapter::Codex
|
||||||
|
.install_command()
|
||||||
|
.contains("@agentclientprotocol/codex-acp"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn spawn_selects_the_codex_adapter() {
|
||||||
|
let fake = Fake::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
|
||||||
|
.await
|
||||||
|
.expect("spawn Codex adapter");
|
||||||
|
assert!(fake.log().contains("recv session/new"));
|
||||||
|
session
|
||||||
|
.terminate(Duration::from_millis(100), Duration::from_secs(2))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn session_updates_reach_the_taken_receiver() {
|
||||||
|
let fake = Fake::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let session = maestro_acp::spawn(fake.config(cwd.path()))
|
||||||
|
.await
|
||||||
|
.expect("spawn");
|
||||||
|
|
||||||
|
let mut updates = session.take_updates().expect("first take");
|
||||||
|
assert!(session.take_updates().is_none(), "handed out once");
|
||||||
|
let first = tokio::time::timeout(Duration::from_secs(5), updates.recv())
|
||||||
|
.await
|
||||||
|
.expect("update within 5s")
|
||||||
|
.expect("channel open");
|
||||||
|
let maestro_acp::AcpUpdate::Notification(first) = first else {
|
||||||
|
panic!("expected Notification");
|
||||||
|
};
|
||||||
|
assert_eq!(first.session_id.0.as_ref(), session.session_id());
|
||||||
|
match first.update {
|
||||||
|
SessionUpdate::AgentMessageChunk(chunk) => match chunk.content {
|
||||||
|
ContentBlock::Text(t) => assert_eq!(t.text, "hello from fake"),
|
||||||
|
other => panic!("unexpected content {other:?}"),
|
||||||
|
},
|
||||||
|
other => panic!("unexpected update {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
session
|
||||||
|
.terminate(Duration::from_millis(100), Duration::from_secs(2))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn advertised_settings_are_normalized_and_mutable() {
|
||||||
|
let fake = Fake::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let mut config = fake.config(cwd.path());
|
||||||
|
config.env.insert("FAKE_ACP_SETTINGS".into(), "1".into());
|
||||||
|
let session = maestro_acp::spawn(config).await.expect("spawn");
|
||||||
|
let mut updates = session.take_updates().expect("updates");
|
||||||
|
|
||||||
|
let initial = loop {
|
||||||
|
let update = tokio::time::timeout(Duration::from_secs(5), updates.recv())
|
||||||
|
.await
|
||||||
|
.expect("settings update")
|
||||||
|
.expect("channel open");
|
||||||
|
let maestro_acp::AcpUpdate::Notification(n) = update else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let SessionUpdate::ConfigOptionUpdate(options) = n.update {
|
||||||
|
break options.config_options;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert_eq!(initial.len(), 3);
|
||||||
|
assert!(initial
|
||||||
|
.iter()
|
||||||
|
.any(|option| option.id.0.as_ref() == "effort"));
|
||||||
|
assert!(initial
|
||||||
|
.iter()
|
||||||
|
.any(|option| option.id.0.as_ref() == "__maestro_legacy_model"));
|
||||||
|
assert!(initial
|
||||||
|
.iter()
|
||||||
|
.any(|option| option.id.0.as_ref() == "__maestro_legacy_mode"));
|
||||||
|
let model = initial
|
||||||
|
.iter()
|
||||||
|
.find(|option| option.id.0.as_ref() == "__maestro_legacy_model")
|
||||||
|
.expect("legacy model option");
|
||||||
|
let model = serde_json::to_value(model).unwrap();
|
||||||
|
assert_eq!(model["options"][0]["name"], "Opus");
|
||||||
|
assert!(model["options"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|option| option["value"] == "fable"));
|
||||||
|
|
||||||
|
let changed = session
|
||||||
|
.set_config(
|
||||||
|
"__maestro_legacy_model".into(),
|
||||||
|
maestro_acp::schema::SessionConfigOptionValue::value_id("sonnet"),
|
||||||
|
)
|
||||||
|
.expect("queue model change");
|
||||||
|
changed
|
||||||
|
.await
|
||||||
|
.expect("response delivered")
|
||||||
|
.expect("model changed");
|
||||||
|
fake.wait_for_log("recv session/set_model").await;
|
||||||
|
|
||||||
|
session
|
||||||
|
.terminate(Duration::from_millis(100), Duration::from_secs(2))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn terminate_sends_cancel_then_sigterm() {
|
||||||
|
let fake = Fake::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let session = maestro_acp::spawn(fake.config(cwd.path()).adapter(AcpAdapter::Codex))
|
||||||
|
.await
|
||||||
|
.expect("spawn");
|
||||||
|
let pid = session.pid();
|
||||||
|
|
||||||
|
session
|
||||||
|
.terminate(Duration::from_millis(200), Duration::from_secs(2))
|
||||||
|
.await;
|
||||||
|
assert!(session.has_exited());
|
||||||
|
fake.wait_for_log("signal SIGTERM").await;
|
||||||
|
|
||||||
|
let log = fake.log();
|
||||||
|
let cancel = log.find("recv session/cancel").expect("cancel logged");
|
||||||
|
let term = log.find("signal SIGTERM").expect("SIGTERM logged");
|
||||||
|
assert!(cancel < term, "cancel must precede SIGTERM:\n{log}");
|
||||||
|
assert!(!pid_alive(pid), "child {pid} still alive");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn terminate_is_satisfied_by_an_exit_on_cancel() {
|
||||||
|
let fake = Fake::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let mut config = fake.config(cwd.path());
|
||||||
|
config
|
||||||
|
.env
|
||||||
|
.insert("FAKE_ACP_EXIT_ON_CANCEL".into(), "1".into());
|
||||||
|
let session = maestro_acp::spawn(config).await.expect("spawn");
|
||||||
|
|
||||||
|
session
|
||||||
|
.terminate(Duration::from_secs(2), Duration::from_secs(2))
|
||||||
|
.await;
|
||||||
|
assert!(session.has_exited());
|
||||||
|
let log = fake.log();
|
||||||
|
assert!(log.contains("recv session/cancel"), "{log}");
|
||||||
|
assert!(!log.contains("signal SIGTERM"), "cancel was enough:\n{log}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handshake_error_fails_the_spawn_and_reaps_the_child() {
|
||||||
|
let fake = Fake::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let mut config = fake.config(cwd.path()).adapter(AcpAdapter::Codex);
|
||||||
|
config.env.insert("FAKE_ACP_INIT_FAIL".into(), "1".into());
|
||||||
|
|
||||||
|
let err = maestro_acp::spawn(config).await.expect_err("init fails");
|
||||||
|
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
|
||||||
|
assert!(err.to_string().contains("fake init failure"), "{err}");
|
||||||
|
// Closing the connection gives the fake EOF on stdin; the SIGTERM that
|
||||||
|
// follows may find it already gone. Either way it must not survive.
|
||||||
|
let pid: u32 = fake
|
||||||
|
.log()
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| l.strip_prefix("start pid="))
|
||||||
|
.and_then(|rest| rest.split(' ').next())
|
||||||
|
.and_then(|p| p.parse().ok())
|
||||||
|
.expect("pid in log");
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(5);
|
||||||
|
while pid_alive(pid) && Instant::now() < deadline {
|
||||||
|
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||||
|
}
|
||||||
|
assert!(!pid_alive(pid), "child {pid} outlived the failed spawn");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn early_exit_is_observed_after_a_successful_handshake() {
|
||||||
|
let fake = Fake::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let mut config = fake.config(cwd.path());
|
||||||
|
config
|
||||||
|
.env
|
||||||
|
.insert("FAKE_ACP_EXIT_AFTER_NEW".into(), "1".into());
|
||||||
|
let session = maestro_acp::spawn(config).await.expect("spawn");
|
||||||
|
|
||||||
|
let status = tokio::time::timeout(Duration::from_secs(5), session.wait())
|
||||||
|
.await
|
||||||
|
.expect("exit within 5s")
|
||||||
|
.expect("status");
|
||||||
|
assert!(status.success(), "{status}");
|
||||||
|
assert!(session.has_exited());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn child_exit_before_handshake_reports_exited_early() {
|
||||||
|
// A "adapter" that dies before answering anything.
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let bin = dir.path().join("bin");
|
||||||
|
std::fs::create_dir_all(&bin).unwrap();
|
||||||
|
let script = bin.join(ADAPTER_BIN);
|
||||||
|
std::fs::write(&script, "#!/bin/sh\necho 'not logged in' >&2\nexit 3\n").unwrap();
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||||
|
}
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let env = HashMap::from([("PATH".to_string(), bin.to_string_lossy().into_owned())]);
|
||||||
|
let mut config = SpawnConfig::new(cwd.path(), env);
|
||||||
|
config.handshake_timeout = Duration::from_secs(10);
|
||||||
|
|
||||||
|
let err = maestro_acp::spawn(config).await.expect_err("child died");
|
||||||
|
match &err {
|
||||||
|
AcpError::ExitedEarly { status, stderr, .. } => {
|
||||||
|
assert!(status.contains('3'), "{status}");
|
||||||
|
assert!(stderr.contains("not logged in"), "{stderr}");
|
||||||
|
}
|
||||||
|
other => panic!("unexpected {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn drop_terminates_and_reaps_the_child() {
|
||||||
|
let fake = Fake::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let mut config = fake.config(cwd.path());
|
||||||
|
config.env.insert("FAKE_ACP_IGNORE_EOF".into(), "1".into());
|
||||||
|
let session = maestro_acp::spawn(config).await.expect("spawn");
|
||||||
|
|
||||||
|
drop(session);
|
||||||
|
|
||||||
|
fake.wait_for_log("signal SIGTERM").await;
|
||||||
|
fake.wait_for_exit().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chunk_text(update: &maestro_acp::AcpUpdate) -> String {
|
||||||
|
let maestro_acp::AcpUpdate::Notification(n) = update else {
|
||||||
|
panic!("expected Notification, got {update:?}");
|
||||||
|
};
|
||||||
|
match &n.update {
|
||||||
|
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
|
||||||
|
ContentBlock::Text(t) => t.text.clone(),
|
||||||
|
other => panic!("unexpected content {other:?}"),
|
||||||
|
},
|
||||||
|
other => panic!("unexpected update {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
|
||||||
|
let fake = Fake::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let mut config = fake
|
||||||
|
.config(cwd.path())
|
||||||
|
.adapter(AcpAdapter::Codex)
|
||||||
|
.load_session("fake-old");
|
||||||
|
config.env.insert("FAKE_ACP_LOAD_REPLAY".into(), "3".into());
|
||||||
|
|
||||||
|
let session = maestro_acp::spawn(config).await.expect("spawn");
|
||||||
|
assert_eq!(session.session_id(), "fake-old");
|
||||||
|
let log = fake.log();
|
||||||
|
assert!(
|
||||||
|
log.contains("recv session/load sessionId=fake-old"),
|
||||||
|
"{log}"
|
||||||
|
);
|
||||||
|
assert!(!log.contains("recv session/new"), "{log}");
|
||||||
|
|
||||||
|
// The three replayed chunks never reach the receiver: the event log
|
||||||
|
// already holds them. The first notification after the response does.
|
||||||
|
let mut updates = session.take_updates().expect("updates");
|
||||||
|
let first = tokio::time::timeout(Duration::from_secs(5), updates.recv())
|
||||||
|
.await
|
||||||
|
.expect("update within 5s")
|
||||||
|
.expect("channel open");
|
||||||
|
assert_eq!(chunk_text(&first), "hello after load");
|
||||||
|
// Nothing queued behind it either.
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
assert!(updates.try_recv().is_err(), "replay leaked through");
|
||||||
|
|
||||||
|
session
|
||||||
|
.terminate(Duration::from_millis(100), Duration::from_secs(2))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn load_session_requires_the_load_capability() {
|
||||||
|
let fake = Fake::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let mut config = fake
|
||||||
|
.config(cwd.path())
|
||||||
|
.adapter(AcpAdapter::Codex)
|
||||||
|
.load_session("fake-old");
|
||||||
|
config.env.insert("FAKE_ACP_NO_LOAD".into(), "1".into());
|
||||||
|
|
||||||
|
let err = maestro_acp::spawn(config)
|
||||||
|
.await
|
||||||
|
.expect_err("no loadSession");
|
||||||
|
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
|
||||||
|
assert!(err.to_string().contains("loadSession"), "{err}");
|
||||||
|
assert!(!fake.log().contains("recv session/load"), "{}", fake.log());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn load_session_error_fails_the_spawn() {
|
||||||
|
let fake = Fake::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let mut config = fake
|
||||||
|
.config(cwd.path())
|
||||||
|
.adapter(AcpAdapter::Codex)
|
||||||
|
.load_session("fake-old");
|
||||||
|
config.env.insert("FAKE_ACP_LOAD_FAIL".into(), "1".into());
|
||||||
|
|
||||||
|
let err = maestro_acp::spawn(config).await.expect_err("load fails");
|
||||||
|
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
|
||||||
|
assert!(err.to_string().contains("fake load failure"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End to end against the installed `claude-code-acp`. Needs Claude auth,
|
||||||
|
/// takes seconds, so it is opt-in: `cargo test -p maestro-acp -- --ignored`.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn real_adapter_handshake() {
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let session = maestro_acp::spawn(SpawnConfig::new(cwd.path(), HashMap::new()))
|
||||||
|
.await
|
||||||
|
.expect("real claude-code-acp handshake");
|
||||||
|
assert!(!session.session_id().is_empty());
|
||||||
|
assert!(pid_alive(session.pid()));
|
||||||
|
session
|
||||||
|
.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
||||||
|
.await;
|
||||||
|
assert!(session.has_exited());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End to end against the maintained Codex adapter. It needs a locally
|
||||||
|
/// authenticated Codex CLI, so CI runs it only when explicitly requested.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore]
|
||||||
|
async fn real_codex_adapter_handshake() {
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let session =
|
||||||
|
maestro_acp::spawn(SpawnConfig::new(cwd.path(), HashMap::new()).adapter(AcpAdapter::Codex))
|
||||||
|
.await
|
||||||
|
.expect("real codex-acp handshake");
|
||||||
|
assert!(!session.session_id().is_empty());
|
||||||
|
assert!(pid_alive(session.pid()));
|
||||||
|
session
|
||||||
|
.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
|
||||||
|
.await;
|
||||||
|
assert!(session.has_exited());
|
||||||
|
}
|
||||||
@@ -19,6 +19,12 @@ use tokio_tungstenite::tungstenite::Message;
|
|||||||
pub enum AttachError {
|
pub enum AttachError {
|
||||||
#[error("ws: {0}")]
|
#[error("ws: {0}")]
|
||||||
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
|
WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
|
||||||
|
#[error("daemon returned {status} ({code}): {message}")]
|
||||||
|
Rejected {
|
||||||
|
status: String,
|
||||||
|
code: String,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
#[error("io: {0}")]
|
#[error("io: {0}")]
|
||||||
Io(#[from] io::Error),
|
Io(#[from] io::Error),
|
||||||
#[error("termios: {0}")]
|
#[error("termios: {0}")]
|
||||||
@@ -32,6 +38,21 @@ pub enum AttachError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AttachError {
|
impl AttachError {
|
||||||
|
fn from_websocket(error: tokio_tungstenite::tungstenite::Error) -> Self {
|
||||||
|
if let tokio_tungstenite::tungstenite::Error::Http(response) = &error {
|
||||||
|
if let Some(body) = response.body().as_deref() {
|
||||||
|
if let Ok(error_body) = serde_json::from_slice::<maestro_protocol::ApiError>(body) {
|
||||||
|
return Self::Rejected {
|
||||||
|
status: response.status().to_string(),
|
||||||
|
code: error_body.error.code,
|
||||||
|
message: error_body.error.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Self::WebSocket(error)
|
||||||
|
}
|
||||||
|
|
||||||
/// True when the failure was an inability to reach the WS endpoint at the
|
/// True when the failure was an inability to reach the WS endpoint at the
|
||||||
/// transport level (connection refused), as opposed to an HTTP-level
|
/// transport level (connection refused), as opposed to an HTTP-level
|
||||||
/// rejection from a daemon that did answer.
|
/// rejection from a daemon that did answer.
|
||||||
@@ -111,7 +132,9 @@ fn current_winsize() -> Option<(u16, u16)> {
|
|||||||
/// byte that exits cleanly without killing the session.
|
/// byte that exits cleanly without killing the session.
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
|
pub async fn run_attach_url(url: &str, detach_byte: u8) -> Result<(), AttachError> {
|
||||||
let (ws, _resp) = tokio_tungstenite::connect_async(url).await?;
|
let (ws, _resp) = tokio_tungstenite::connect_async(url)
|
||||||
|
.await
|
||||||
|
.map_err(AttachError::from_websocket)?;
|
||||||
let (mut sink, mut stream) = ws.split();
|
let (mut sink, mut stream) = ws.split();
|
||||||
|
|
||||||
let raw = RawTty::enable()?;
|
let raw = RawTty::enable()?;
|
||||||
@@ -233,4 +256,25 @@ mod tests {
|
|||||||
Err(AttachError::InvalidDetachKey(_))
|
Err(AttachError::InvalidDetachKey(_))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attach_surfaces_structured_handshake_errors() {
|
||||||
|
let body = serde_json::to_vec(&maestro_protocol::ApiError::new(
|
||||||
|
"not_terminal",
|
||||||
|
"session is not a terminal session",
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let response = tokio_tungstenite::tungstenite::http::Response::builder()
|
||||||
|
.status(409)
|
||||||
|
.body(Some(body))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error =
|
||||||
|
AttachError::from_websocket(tokio_tungstenite::tungstenite::Error::Http(response));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
"daemon returned 409 Conflict (not_terminal): session is not a terminal session"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ pub enum Command {
|
|||||||
Attach(AttachArgs),
|
Attach(AttachArgs),
|
||||||
/// Kill a session by ID.
|
/// Kill a session by ID.
|
||||||
Kill(KillArgs),
|
Kill(KillArgs),
|
||||||
/// Type text into a running session, as if it had been typed on an
|
/// Send text to a running session. Terminal sessions receive raw
|
||||||
/// attached terminal.
|
/// keystrokes; structured sessions receive an ACP prompt.
|
||||||
Send(SendArgs),
|
Send(SendArgs),
|
||||||
/// Install or update the agent activity hook (#106).
|
/// Install or update the agent activity hook (#106).
|
||||||
Hooks(HooksArgs),
|
Hooks(HooksArgs),
|
||||||
@@ -92,6 +92,14 @@ pub struct SpawnArgs {
|
|||||||
/// there.
|
/// there.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub existing_branch: bool,
|
pub existing_branch: bool,
|
||||||
|
/// Run a Claude Code or Codex session in a plain terminal instead of the
|
||||||
|
/// structured (ACP) view it otherwise gets by default.
|
||||||
|
#[arg(long, conflicts_with = "structured")]
|
||||||
|
pub terminal: bool,
|
||||||
|
/// Ask for the structured (ACP) view explicitly. Only Claude Code and
|
||||||
|
/// Codex commands have an adapter; the daemon refuses anything else.
|
||||||
|
#[arg(long)]
|
||||||
|
pub structured: bool,
|
||||||
/// After spawning, attach immediately.
|
/// After spawning, attach immediately.
|
||||||
#[arg(long, short = 'a')]
|
#[arg(long, short = 'a')]
|
||||||
pub attach: bool,
|
pub attach: bool,
|
||||||
@@ -125,7 +133,7 @@ pub struct KillArgs {
|
|||||||
/// Session ID to kill.
|
/// Session ID to kill.
|
||||||
pub session_id: Uuid,
|
pub session_id: Uuid,
|
||||||
/// End the session even when its worktree holds unsaved work (#81). The
|
/// End the session even when its worktree holds unsaved work (#81). The
|
||||||
/// worktree is left on disk — never removed.
|
/// worktree is removed along with the session.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub force: bool,
|
pub force: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,20 +96,32 @@ pub async fn dispatch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `maestro hooks install` (#106): write the shipped hook script under the
|
/// `maestro hooks install` (#106): write the shipped hook script under the
|
||||||
/// user's data dir and register it in their Claude Code settings. Idempotent
|
/// user's data dir and register it in every agent's hook file. Idempotent and
|
||||||
/// and foreign-hook-preserving — the guarantees live in [`maestro_hooks`].
|
/// foreign-hook-preserving — the guarantees live in [`maestro_hooks`]. One
|
||||||
|
/// agent failing is reported and does not stop the others.
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
fn run_hooks_install() -> Result<(), CliError> {
|
fn run_hooks_install() -> Result<(), CliError> {
|
||||||
let home = dirs::home_dir().ok_or(CliError::NoHome)?;
|
let home = dirs::home_dir().ok_or(CliError::NoHome)?;
|
||||||
let changed = maestro_hooks::install_for_home(&home)?;
|
let script = maestro_hooks::script_path(&home);
|
||||||
if changed {
|
for (target, result) in maestro_hooks::install_for_home(&home) {
|
||||||
println!(
|
let agent = target.label();
|
||||||
"installed claude-code activity hook: {} → {}",
|
let settings = target.settings_path(&home);
|
||||||
maestro_hooks::script_path(&home).display(),
|
match result {
|
||||||
maestro_hooks::settings_path(&home).display()
|
Ok(true) => {
|
||||||
);
|
println!(
|
||||||
} else {
|
"installed {agent} activity hook: {} → {}",
|
||||||
println!("claude-code activity hook already up to date");
|
script.display(),
|
||||||
|
settings.display()
|
||||||
|
);
|
||||||
|
if target == maestro_hooks::Target::Codex {
|
||||||
|
// Codex will not run a hook it has not been told to
|
||||||
|
// trust, and that approval is the operator's to give.
|
||||||
|
println!(" codex will ask you to trust this hook the next time it starts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false) => println!("{agent} activity hook already up to date"),
|
||||||
|
Err(e) => eprintln!("{agent} activity hook not installed: {e}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
use maestro_protocol::display::abbreviate_dir;
|
use maestro_protocol::display::{abbreviate_dir, supports_structured_view};
|
||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, SendInputRequest,
|
||||||
Session, SessionStatus, WorktreeSpec,
|
Session, SessionStatus, SessionView, WorktreeSpec,
|
||||||
};
|
};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
@@ -51,15 +51,33 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
|||||||
};
|
};
|
||||||
|
|
||||||
let body = CreateSessionRequest {
|
let body = CreateSessionRequest {
|
||||||
|
view: spawn_view(&args),
|
||||||
cwd: args.cwd.clone(),
|
cwd: args.cwd.clone(),
|
||||||
command: args.command.clone(),
|
command: args.command.clone(),
|
||||||
env,
|
env,
|
||||||
metadata,
|
metadata,
|
||||||
worktree,
|
worktree,
|
||||||
|
auto_approve: false,
|
||||||
};
|
};
|
||||||
client.create_session(&body).await
|
client.create_session(&body).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The view a `maestro spawn` asks for: structured whenever the command
|
||||||
|
/// resolves to an agent with an ACP adapter, since that is the richer session
|
||||||
|
/// and the one both other clients now default to. `--terminal` opts out;
|
||||||
|
/// `--structured` forces the request through even for a command this side
|
||||||
|
/// does not recognise, leaving the refusal to the daemon.
|
||||||
|
fn spawn_view(args: &SpawnArgs) -> SessionView {
|
||||||
|
if args.terminal {
|
||||||
|
return SessionView::Terminal;
|
||||||
|
}
|
||||||
|
if args.structured || supports_structured_view(&args.command) {
|
||||||
|
SessionView::Structured
|
||||||
|
} else {
|
||||||
|
SessionView::Terminal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
||||||
/// of removing it; the outcome names what was kept so dispatch can say where
|
/// of removing it; the outcome names what was kept so dispatch can say where
|
||||||
/// the work went.
|
/// the work went.
|
||||||
@@ -112,9 +130,12 @@ pub fn resolve_session(sessions: &[Session], needle: &str) -> Result<uuid::Uuid,
|
|||||||
/// `maestro send`. Answers with the session that was typed into, so the
|
/// `maestro send`. Answers with the session that was typed into, so the
|
||||||
/// caller can name it once the daemon has accepted the text.
|
/// caller can name it once the daemon has accepted the text.
|
||||||
///
|
///
|
||||||
/// A bare ID skips the catalog fetch; anything else needs the list to match
|
/// Terminal sessions get raw keystrokes via `send_input`; structured sessions
|
||||||
/// against. Text comes from the arguments, or from stdin when there is none —
|
/// get an ACP prompt via `send_structured_prompt` (`--no-enter` is ignored
|
||||||
/// the shape a heredoc or a pipe wants.
|
/// there — ACP prompts are always complete messages).
|
||||||
|
///
|
||||||
|
/// Text comes from the arguments, or from stdin when there is none — the
|
||||||
|
/// shape a heredoc or a pipe wants.
|
||||||
#[allow(clippy::result_large_err)]
|
#[allow(clippy::result_large_err)]
|
||||||
pub async fn run_send(
|
pub async fn run_send(
|
||||||
client: &DaemonClient,
|
client: &DaemonClient,
|
||||||
@@ -137,19 +158,28 @@ pub async fn run_send(
|
|||||||
return Err(CliError::NothingToSend);
|
return Err(CliError::NothingToSend);
|
||||||
}
|
}
|
||||||
|
|
||||||
let id = match uuid::Uuid::parse_str(args.session.trim()) {
|
// We always need the session list: even a bare UUID needs the view field
|
||||||
Ok(id) => id,
|
// to decide which endpoint to hit.
|
||||||
Err(_) => {
|
let sessions = client.list_sessions().await?;
|
||||||
let sessions = client.list_sessions().await?;
|
let id = resolve_session(&sessions, &args.session)?;
|
||||||
resolve_session(&sessions, &args.session)?
|
let view = sessions
|
||||||
}
|
.iter()
|
||||||
};
|
.find(|s| s.id == id)
|
||||||
|
.map(|s| s.view)
|
||||||
|
.unwrap_or(SessionView::Terminal);
|
||||||
|
|
||||||
let req = SendInputRequest {
|
match view {
|
||||||
text,
|
SessionView::Structured => {
|
||||||
submit: !args.no_enter,
|
client.send_structured_prompt(id, &text).await?;
|
||||||
};
|
}
|
||||||
client.send_input(id, &req).await?;
|
SessionView::Terminal => {
|
||||||
|
let req = SendInputRequest {
|
||||||
|
text,
|
||||||
|
submit: !args.no_enter,
|
||||||
|
};
|
||||||
|
client.send_input(id, &req).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,8 +208,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
}
|
}
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {}",
|
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {}",
|
||||||
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
"ID", "STATUS", "VIEW", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
for s in sorted {
|
for s in sorted {
|
||||||
@@ -189,11 +219,16 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
SessionStatus::Running => "running",
|
SessionStatus::Running => "running",
|
||||||
SessionStatus::Dead => "dead",
|
SessionStatus::Dead => "dead",
|
||||||
};
|
};
|
||||||
|
let view = match s.view {
|
||||||
|
SessionView::Terminal => "terminal",
|
||||||
|
SessionView::Structured => "structured",
|
||||||
|
};
|
||||||
let activity = match s.activity {
|
let activity = match s.activity {
|
||||||
ActivityState::Active => "active",
|
ActivityState::Active => "active",
|
||||||
ActivityState::Idle => "idle",
|
ActivityState::Idle => "idle",
|
||||||
ActivityState::AwaitingInput => "awaiting",
|
ActivityState::AwaitingInput => "awaiting",
|
||||||
ActivityState::Error => "error",
|
ActivityState::Error => "error",
|
||||||
|
ActivityState::Starting => "starting",
|
||||||
};
|
};
|
||||||
// Show the live foreground command alongside the spawn command when
|
// Show the live foreground command alongside the spawn command when
|
||||||
// the daemon has observed one that differs (NAP-340).
|
// the daemon has observed one that differs (NAP-340).
|
||||||
@@ -206,8 +241,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
let blocked = blocked_label(s);
|
let blocked = blocked_label(s);
|
||||||
writeln!(
|
writeln!(
|
||||||
out,
|
out,
|
||||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}",
|
" {:<36} {:<8} {:<10} {:<9} {:<28} {:<40} {blocked}",
|
||||||
s.id, status, activity, dir, command
|
s.id, status, view, activity, dir, command
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
@@ -229,13 +264,70 @@ mod tests {
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use chrono::{TimeZone, Utc};
|
use chrono::{TimeZone, Utc};
|
||||||
use maestro_protocol::{ActivityState, Session, SessionStatus};
|
use maestro_protocol::{ActivityState, Session, SessionStatus, SessionView};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::DaemonInfo;
|
use super::DaemonInfo;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
use crate::cli::{Cli, Command};
|
||||||
|
|
||||||
|
fn spawn_args(extra: &[&str]) -> crate::cli::SpawnArgs {
|
||||||
|
let mut argv = vec!["maestro", "spawn", "--cwd", "/tmp", "--command"];
|
||||||
|
argv.extend_from_slice(extra);
|
||||||
|
let cli = Cli::try_parse_from(argv).expect("parses");
|
||||||
|
let Command::Spawn(args) = cli.command else {
|
||||||
|
panic!("expected spawn")
|
||||||
|
};
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The CLI follows the two interactive clients: a command with an ACP
|
||||||
|
/// adapter spawns structured unless `--terminal` says otherwise, and
|
||||||
|
/// `--structured` still forces the request for a command this side does
|
||||||
|
/// not recognise.
|
||||||
|
#[test]
|
||||||
|
fn spawn_defaults_to_structured_for_supported_agents() {
|
||||||
|
use super::spawn_view;
|
||||||
|
assert_eq!(
|
||||||
|
spawn_view(&spawn_args(&["claude"])),
|
||||||
|
SessionView::Structured
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
spawn_view(&spawn_args(&["npx codex exec"])),
|
||||||
|
SessionView::Structured
|
||||||
|
);
|
||||||
|
assert_eq!(spawn_view(&spawn_args(&["bash"])), SessionView::Terminal);
|
||||||
|
assert_eq!(
|
||||||
|
spawn_view(&spawn_args(&["claude", "--terminal"])),
|
||||||
|
SessionView::Terminal
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
spawn_view(&spawn_args(&["opencode", "--structured"])),
|
||||||
|
SessionView::Structured
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawn_refuses_both_view_flags_at_once() {
|
||||||
|
let err = Cli::try_parse_from([
|
||||||
|
"maestro",
|
||||||
|
"spawn",
|
||||||
|
"--cwd",
|
||||||
|
"/tmp",
|
||||||
|
"--command",
|
||||||
|
"claude",
|
||||||
|
"--structured",
|
||||||
|
"--terminal",
|
||||||
|
])
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("--terminal"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
fn session(cwd: &str, command: &str, id: u128) -> Session {
|
fn session(cwd: &str, command: &str, id: u128) -> Session {
|
||||||
Session {
|
Session {
|
||||||
|
view: Default::default(),
|
||||||
id: Uuid::from_u128(id),
|
id: Uuid::from_u128(id),
|
||||||
cwd: PathBuf::from(cwd),
|
cwd: PathBuf::from(cwd),
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
@@ -252,6 +344,9 @@ mod tests {
|
|||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
last_focused_at: None,
|
last_focused_at: None,
|
||||||
|
acp_session_id: None,
|
||||||
|
auto_approve: false,
|
||||||
|
queued_prompts: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,6 +378,22 @@ mod tests {
|
|||||||
assert!(s.contains("idle"), "{s}");
|
assert!(s.contains("idle"), "{s}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_exposes_terminal_and_structured_views() {
|
||||||
|
let terminal = session("/tmp", "bash", 1);
|
||||||
|
let mut structured = session("/tmp", "claude", 2);
|
||||||
|
structured.view = SessionView::Structured;
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
super::print_session_table(&mut buf, &[terminal, structured], &info(""));
|
||||||
|
let out = String::from_utf8(buf).unwrap();
|
||||||
|
let lines: Vec<&str> = out.lines().collect();
|
||||||
|
let header = lines.iter().position(|line| line.contains("VIEW")).unwrap();
|
||||||
|
|
||||||
|
assert!(lines[header + 1].contains("terminal"), "{out}");
|
||||||
|
assert!(lines[header + 2].contains("structured"), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn live_cwd_overrides_spawn_dir_in_table() {
|
fn live_cwd_overrides_spawn_dir_in_table() {
|
||||||
let mut s = session("/home/naps/maestro", "bash", 1);
|
let mut s = session("/home/naps/maestro", "bash", 1);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! was unit-tested, but nothing exercised the client, the error decoding, or
|
//! was unit-tested, but nothing exercised the client, the error decoding, or
|
||||||
//! the unreachable-daemon path without compiling and forking a real daemon.
|
//! the unreachable-daemon path without compiling and forking a real daemon.
|
||||||
|
|
||||||
use maestro_protocol::{DaemonClient, DaemonInfo};
|
use maestro_protocol::{DaemonClient, DaemonInfo, SessionView};
|
||||||
use maestro_testing::{session, MockDaemon};
|
use maestro_testing::{session, MockDaemon};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -28,6 +28,7 @@ async fn info_carries_version_and_presets() {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
}],
|
}],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
@@ -91,11 +92,13 @@ async fn spawn_round_trips_through_the_daemon() {
|
|||||||
let created = mock
|
let created = mock
|
||||||
.client()
|
.client()
|
||||||
.create_session(&maestro_protocol::CreateSessionRequest {
|
.create_session(&maestro_protocol::CreateSessionRequest {
|
||||||
|
view: Default::default(),
|
||||||
cwd: "/home/mock".into(),
|
cwd: "/home/mock".into(),
|
||||||
command: "bash".into(),
|
command: "bash".into(),
|
||||||
env: Default::default(),
|
env: Default::default(),
|
||||||
metadata: Default::default(),
|
metadata: Default::default(),
|
||||||
worktree: None,
|
worktree: None,
|
||||||
|
auto_approve: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.expect("create");
|
.expect("create");
|
||||||
@@ -199,6 +202,35 @@ async fn send_resolves_a_session_by_name() {
|
|||||||
assert_eq!(sent, id);
|
assert_eq!(sent, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A structured session routes through the ACP prompt endpoint, not the
|
||||||
|
/// terminal input one — `--no-enter` is irrelevant (ACP prompts are
|
||||||
|
/// complete messages).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn send_to_structured_session_uses_acp_prompt() {
|
||||||
|
let mock = MockDaemon::start().await;
|
||||||
|
let id = Uuid::from_u128(13);
|
||||||
|
let mut s = session(id, "/tmp", "claude");
|
||||||
|
s.view = SessionView::Structured;
|
||||||
|
mock.add_session(s);
|
||||||
|
|
||||||
|
let args = maestro_cli::cli::SendArgs {
|
||||||
|
session: id.to_string(),
|
||||||
|
text: vec!["fix".into(), "the".into(), "tests".into()],
|
||||||
|
no_enter: false,
|
||||||
|
};
|
||||||
|
let sent = maestro_cli::sessions::run_send(&mock.client(), &args, &mut std::io::empty())
|
||||||
|
.await
|
||||||
|
.expect("send");
|
||||||
|
|
||||||
|
assert_eq!(sent, id);
|
||||||
|
// Terminal input must stay empty — the message went through ACP.
|
||||||
|
assert!(mock.inputs().is_empty());
|
||||||
|
let prompts = mock.structured_prompts();
|
||||||
|
assert_eq!(prompts.len(), 1);
|
||||||
|
assert_eq!(prompts[0].0, id);
|
||||||
|
assert_eq!(prompts[0].1.prompt, "fix the tests");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
async fn send_to_an_unknown_session_fails_without_typing_anything() {
|
||||||
let mock = MockDaemon::start().await;
|
let mock = MockDaemon::start().await;
|
||||||
|
|||||||
@@ -313,16 +313,19 @@ pub fn builtin_presets() -> Vec<SpawnPreset> {
|
|||||||
id: "claude".into(),
|
id: "claude".into(),
|
||||||
label: "Claude".into(),
|
label: "Claude".into(),
|
||||||
command: "claude".into(),
|
command: "claude".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "opencode".into(),
|
id: "opencode".into(),
|
||||||
label: "opencode".into(),
|
label: "opencode".into(),
|
||||||
command: "opencode".into(),
|
command: "opencode".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "zsh".into(),
|
id: "zsh".into(),
|
||||||
label: "Shell".into(),
|
label: "Shell".into(),
|
||||||
command: "zsh".into(),
|
command: "zsh".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -334,6 +337,7 @@ pub fn custom_preset() -> SpawnPreset {
|
|||||||
id: CUSTOM_PRESET_ID.into(),
|
id: CUSTOM_PRESET_ID.into(),
|
||||||
label: "Custom…".into(),
|
label: "Custom…".into(),
|
||||||
command: String::new(),
|
command: String::new(),
|
||||||
|
auto_approve: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +519,7 @@ auto_install = false
|
|||||||
id: "fish".into(),
|
id: "fish".into(),
|
||||||
label: "fish".into(),
|
label: "fish".into(),
|
||||||
command: "fish".into(),
|
command: "fish".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -551,6 +556,7 @@ auto_install = false
|
|||||||
id: " ".into(),
|
id: " ".into(),
|
||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
command: "x".into(),
|
command: "x".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -561,6 +567,7 @@ auto_install = false
|
|||||||
id: "x".into(),
|
id: "x".into(),
|
||||||
label: "x".into(),
|
label: "x".into(),
|
||||||
command: "".into(),
|
command: "".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
@@ -578,11 +585,13 @@ auto_install = false
|
|||||||
id: "a".into(),
|
id: "a".into(),
|
||||||
label: "a".into(),
|
label: "a".into(),
|
||||||
command: "a".into(),
|
command: "a".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
SpawnPreset {
|
SpawnPreset {
|
||||||
id: "a".into(),
|
id: "a".into(),
|
||||||
label: "a2".into(),
|
label: "a2".into(),
|
||||||
command: "a2".into(),
|
command: "a2".into(),
|
||||||
|
auto_approve: false,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -597,6 +606,7 @@ auto_install = false
|
|||||||
id: "custom".into(),
|
id: "custom".into(),
|
||||||
label: "Custom".into(),
|
label: "Custom".into(),
|
||||||
command: "whatever".into(),
|
command: "whatever".into(),
|
||||||
|
auto_approve: false,
|
||||||
}]),
|
}]),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ dirs.workspace = true
|
|||||||
futures-util.workspace = true
|
futures-util.workspace = true
|
||||||
include_dir = { workspace = true, optional = true }
|
include_dir = { workspace = true, optional = true }
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
|
maestro-acp.workspace = true
|
||||||
mime_guess = { workspace = true, optional = true }
|
mime_guess = { workspace = true, optional = true }
|
||||||
maestro-config.workspace = true
|
maestro-config.workspace = true
|
||||||
maestro-hooks = { workspace = true, default-features = false }
|
maestro-hooks = { workspace = true, default-features = false }
|
||||||
@@ -42,6 +43,7 @@ tracing-subscriber.workspace = true
|
|||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
libc.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
tokio-tungstenite.workspace = true
|
tokio-tungstenite.workspace = true
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
|
|||||||
@@ -66,6 +66,23 @@ pub fn decide(idle_for: Duration, awaiting_input: bool) -> ActivityState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Derive a structured session's sidebar state from its ACP projection.
|
||||||
|
pub fn decide_structured(
|
||||||
|
turn_active: bool,
|
||||||
|
pending_approval: bool,
|
||||||
|
turn_failed: bool,
|
||||||
|
) -> ActivityState {
|
||||||
|
if turn_failed {
|
||||||
|
ActivityState::Error
|
||||||
|
} else if pending_approval {
|
||||||
|
ActivityState::AwaitingInput
|
||||||
|
} else if turn_active {
|
||||||
|
ActivityState::Active
|
||||||
|
} else {
|
||||||
|
ActivityState::Idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// How long a session's pane must be continuously focused (a client's pane,
|
/// How long a session's pane must be continuously focused (a client's pane,
|
||||||
/// not a sidebar highlight) before an `AwaitingInput` glow clears: the
|
/// not a sidebar highlight) before an `AwaitingInput` glow clears: the
|
||||||
/// operator has looked, so the rail stops asking for attention.
|
/// operator has looked, so the rail stops asking for attention.
|
||||||
@@ -136,6 +153,17 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structured_activity_follows_lifecycle_with_approval_precedence() {
|
||||||
|
assert_eq!(decide_structured(false, false, false), ActivityState::Idle);
|
||||||
|
assert_eq!(decide_structured(true, false, false), ActivityState::Active);
|
||||||
|
assert_eq!(
|
||||||
|
decide_structured(true, true, false),
|
||||||
|
ActivityState::AwaitingInput
|
||||||
|
);
|
||||||
|
assert_eq!(decide_structured(false, false, true), ActivityState::Error);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn report_is_in_force_until_exactly_its_expiry() {
|
fn report_is_in_force_until_exactly_its_expiry() {
|
||||||
let ttl = Duration::from_secs(300);
|
let ttl = Duration::from_secs(300);
|
||||||
|
|||||||
@@ -51,17 +51,23 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
// First-start hook auto-install (#106). Best-effort, before serving: a
|
// First-start hook auto-install (#106). Best-effort, before serving: a
|
||||||
// session spawned right away should already carry the hook.
|
// session spawned right away should already carry the hook.
|
||||||
if config.hooks_auto_install {
|
if config.hooks_auto_install {
|
||||||
match crate::hooks::auto_install(&config.home_dir) {
|
for (target, outcome) in crate::hooks::auto_install(&config.home_dir) {
|
||||||
crate::hooks::AutoInstallOutcome::Installed => {
|
let agent = target.label();
|
||||||
tracing::info!("installed claude-code activity hook (#106)");
|
match outcome {
|
||||||
|
crate::hooks::AutoInstallOutcome::Installed => {
|
||||||
|
tracing::info!(agent, "installed activity hook (#106)");
|
||||||
|
}
|
||||||
|
crate::hooks::AutoInstallOutcome::Upgraded => {
|
||||||
|
tracing::info!(agent, "updated activity hook to this build (#106)");
|
||||||
|
}
|
||||||
|
crate::hooks::AutoInstallOutcome::Failed => {
|
||||||
|
tracing::warn!(
|
||||||
|
agent,
|
||||||
|
"hooks auto-install failed; run `maestro hooks install` manually"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
crate::hooks::AutoInstallOutcome::Upgraded => {
|
|
||||||
tracing::info!("updated claude-code activity hook to this build (#106)");
|
|
||||||
}
|
|
||||||
crate::hooks::AutoInstallOutcome::Failed => {
|
|
||||||
tracing::warn!("hooks auto-install failed; run `maestro hooks install` manually");
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No worktree dir to pre-create: without an override the worktrees live
|
// No worktree dir to pre-create: without an override the worktrees live
|
||||||
@@ -100,7 +106,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
projects.start_background_refresh();
|
projects.start_background_refresh();
|
||||||
|
|
||||||
let router = local_api::router(local_api::state_for(
|
let router = local_api::router(local_api::state_for(
|
||||||
manager,
|
manager.clone(),
|
||||||
config.hostname.clone(),
|
config.hostname.clone(),
|
||||||
config.home_dir.clone(),
|
config.home_dir.clone(),
|
||||||
config.spawn_presets.clone(),
|
config.spawn_presets.clone(),
|
||||||
@@ -112,9 +118,22 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
let bound = listener.local_addr()?;
|
let bound = listener.local_addr()?;
|
||||||
tracing::info!(addr = %bound, "API listening");
|
tracing::info!(addr = %bound, "API listening");
|
||||||
|
|
||||||
let shutdown = async {
|
// Ctrl-C at a terminal or SIGTERM from systemd: either one is a
|
||||||
let _ = tokio::signal::ctrl_c().await;
|
// shutdown, and both must reach the adapter children below (#134). The
|
||||||
tracing::info!("ctrl-c received — shutting down");
|
// unit's `KillMode=process` signals only the daemon, so nothing else
|
||||||
|
// would end them.
|
||||||
|
let shutdown_manager = manager.clone();
|
||||||
|
let shutdown = async move {
|
||||||
|
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
.expect("install SIGTERM handler");
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::signal::ctrl_c() => tracing::info!("ctrl-c received — shutting down"),
|
||||||
|
_ = sigterm.recv() => tracing::info!("SIGTERM received — shutting down"),
|
||||||
|
}
|
||||||
|
// Axum waits for upgraded WebSockets to drain. Structured children
|
||||||
|
// must be settled before that wait, while the signal path still owns
|
||||||
|
// the shutdown order (#179).
|
||||||
|
shutdown_manager.shutdown().await;
|
||||||
};
|
};
|
||||||
|
|
||||||
axum::serve(listener, router)
|
axum::serve(listener, router)
|
||||||
|
|||||||
@@ -80,13 +80,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_repo_dir_is_none() {
|
fn non_repo_dir_does_not_find_root_inside_fixture() {
|
||||||
// A temp dir with no `.git` anywhere up the chain. Use the temp root so
|
// The fixture has no `.git`, so `find_root` must not return a path
|
||||||
// the walk can't accidentally hit a real repo above the test's cwd.
|
// inside it. On hosts where an ancestor of the temp dir *is* a repo
|
||||||
|
// (e.g. `/tmp` lives inside a checkout), the function legitimately
|
||||||
|
// returns that ancestor — the test only asserts the fixture itself
|
||||||
|
// was not mistaken for a repo.
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let sub = tmp.path().join("plain/dir");
|
let sub = tmp.path().join("plain/dir");
|
||||||
fs::create_dir_all(&sub).unwrap();
|
fs::create_dir_all(&sub).unwrap();
|
||||||
assert_eq!(find_root(&sub), None);
|
match find_root(&sub) {
|
||||||
|
None => {} // expected on most hosts
|
||||||
|
Some(root) => assert!(
|
||||||
|
!root.starts_with(tmp.path()),
|
||||||
|
"found .git inside the fixture at {root:?}, but none was created"
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,37 +1,53 @@
|
|||||||
//! First-start hook auto-install (#106).
|
//! First-start hook auto-install (#106).
|
||||||
//!
|
//!
|
||||||
//! The daemon runs [`auto_install`] once at startup: unless the user's
|
//! The daemon runs [`auto_install`] once at startup: for every agent maestro
|
||||||
//! `~/.claude/settings.json` already carries exactly what this build ships
|
//! knows how to hook ([`maestro_hooks::Target`]), unless that agent's hook
|
||||||
//! (and `[hooks].auto_install` hasn't opted out — checked by the caller), the
|
//! file already carries exactly what this build ships (and `[hooks]
|
||||||
//! same installer `maestro hooks install` uses writes it. That covers
|
//! .auto_install` hasn't opted out — checked by the caller), the same
|
||||||
//! upgrades as well as first starts: an install from a maestro that had fewer
|
//! installer `maestro hooks install` uses writes it. That covers upgrades as
|
||||||
//! events — #131 added `UserPromptSubmit` to a hook #106 had already put in
|
//! well as first starts: an install from a maestro that had fewer events —
|
||||||
//! everyone's settings — is rewritten rather than left half-wired. Best-effort:
|
//! #131 added `UserPromptSubmit` to a hook #106 had already put in everyone's
|
||||||
//! a failure is warned about and never blocks startup. The same global hook
|
//! settings — is rewritten rather than left half-wired. Best-effort: a
|
||||||
//! serves every Claude Code session on the machine, so nothing here targets
|
//! failure is warned about and never blocks startup, and one target failing
|
||||||
//! repositories or worktrees.
|
//! does not stop the others. The same global hook serves every session on the
|
||||||
|
//! machine, so nothing here targets repositories or worktrees.
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Install the Claude Code activity hook for `home_dir` unless the current
|
use maestro_hooks::Target;
|
||||||
/// one is already there or the home dir is unresolvable (`""`). Returns what
|
|
||||||
/// happened; an older install being brought up to date is
|
/// Install the activity hook for every [`Target`] under `home_dir`, skipping
|
||||||
/// [`AutoInstallOutcome::Upgraded`], so the log can say so.
|
/// any whose install is already current. Returns what happened per target —
|
||||||
pub fn auto_install(home_dir: &str) -> AutoInstallOutcome {
|
/// an older install being brought up to date is
|
||||||
|
/// [`AutoInstallOutcome::Upgraded`], so the log can say so. An unresolvable
|
||||||
|
/// home dir (`""`) reports every target as [`AutoInstallOutcome::Skipped`].
|
||||||
|
pub fn auto_install(home_dir: &str) -> Vec<(Target, AutoInstallOutcome)> {
|
||||||
|
Target::ALL
|
||||||
|
.iter()
|
||||||
|
.map(|&target| (target, auto_install_target(target, home_dir)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn auto_install_target(target: Target, home_dir: &str) -> AutoInstallOutcome {
|
||||||
if home_dir.is_empty() {
|
if home_dir.is_empty() {
|
||||||
return AutoInstallOutcome::Skipped;
|
return AutoInstallOutcome::Skipped;
|
||||||
}
|
}
|
||||||
let settings = maestro_hooks::settings_path(Path::new(home_dir));
|
let home = Path::new(home_dir);
|
||||||
let script = maestro_hooks::script_path(Path::new(home_dir));
|
let settings = target.settings_path(home);
|
||||||
if maestro_hooks::is_current(&settings, &script) {
|
let script = maestro_hooks::script_path(home);
|
||||||
|
if maestro_hooks::is_current(target, &settings, &script) {
|
||||||
return AutoInstallOutcome::AlreadyPresent;
|
return AutoInstallOutcome::AlreadyPresent;
|
||||||
}
|
}
|
||||||
let stale = maestro_hooks::is_installed(&settings);
|
let stale = maestro_hooks::is_installed(&settings);
|
||||||
match maestro_hooks::install_for_home(Path::new(home_dir)) {
|
match maestro_hooks::install_target(target, home) {
|
||||||
Ok(_) if stale => AutoInstallOutcome::Upgraded,
|
Ok(_) if stale => AutoInstallOutcome::Upgraded,
|
||||||
Ok(_) => AutoInstallOutcome::Installed,
|
Ok(_) => AutoInstallOutcome::Installed,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %e, "hooks auto-install failed (non-fatal)");
|
tracing::warn!(
|
||||||
|
target = target.label(),
|
||||||
|
error = %e,
|
||||||
|
"hooks auto-install failed (non-fatal)"
|
||||||
|
);
|
||||||
AutoInstallOutcome::Failed
|
AutoInstallOutcome::Failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -52,15 +68,50 @@ pub enum AutoInstallOutcome {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// The outcome recorded for one target.
|
||||||
|
fn outcome(outcomes: &[(Target, AutoInstallOutcome)], target: Target) -> AutoInstallOutcome {
|
||||||
|
outcomes
|
||||||
|
.iter()
|
||||||
|
.find(|(t, _)| *t == target)
|
||||||
|
.map(|(_, o)| *o)
|
||||||
|
.expect("every target is reported")
|
||||||
|
}
|
||||||
|
|
||||||
/// Everything happens under a temp `$HOME`; the developer's own
|
/// Everything happens under a temp `$HOME`; the developer's own
|
||||||
/// `~/.claude/settings.json` is never read or written.
|
/// `~/.claude/settings.json` and `~/.codex/hooks.json` are never read or
|
||||||
|
/// written.
|
||||||
#[test]
|
#[test]
|
||||||
fn installs_when_settings_carry_no_maestro_hook() {
|
fn installs_when_settings_carry_no_maestro_hook() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let outcome = auto_install(dir.path().to_str().unwrap());
|
let outcomes = auto_install(dir.path().to_str().unwrap());
|
||||||
assert_eq!(outcome, AutoInstallOutcome::Installed);
|
for target in Target::ALL {
|
||||||
let settings = dir.path().join(".claude/settings.json");
|
assert_eq!(
|
||||||
assert!(maestro_hooks::is_installed(&settings));
|
outcome(&outcomes, target),
|
||||||
|
AutoInstallOutcome::Installed,
|
||||||
|
"{}",
|
||||||
|
target.label()
|
||||||
|
);
|
||||||
|
assert!(maestro_hooks::is_installed(
|
||||||
|
&target.settings_path(dir.path())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every agent maestro knows about is hooked, not just Claude Code: a
|
||||||
|
/// codex session reports activity and its first prompt the same way.
|
||||||
|
#[test]
|
||||||
|
fn codex_gets_its_own_hook_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
auto_install(dir.path().to_str().unwrap());
|
||||||
|
let hooks = dir.path().join(".codex/hooks.json");
|
||||||
|
let root: serde_json::Value =
|
||||||
|
serde_json::from_str(&std::fs::read_to_string(&hooks).unwrap()).unwrap();
|
||||||
|
let events = root["hooks"].as_object().unwrap();
|
||||||
|
assert!(events.contains_key("PermissionRequest"));
|
||||||
|
assert!(
|
||||||
|
!events.contains_key("Notification"),
|
||||||
|
"codex has no Notification"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -69,14 +120,22 @@ mod tests {
|
|||||||
let home = dir.path().to_str().unwrap();
|
let home = dir.path().to_str().unwrap();
|
||||||
auto_install(home);
|
auto_install(home);
|
||||||
// A current install is left exactly as it is.
|
// A current install is left exactly as it is.
|
||||||
let settings = dir.path().join(".claude/settings.json");
|
let before: Vec<String> = Target::ALL
|
||||||
let before = std::fs::read_to_string(&settings).unwrap();
|
.iter()
|
||||||
assert_eq!(auto_install(home), AutoInstallOutcome::AlreadyPresent);
|
.map(|t| std::fs::read_to_string(t.settings_path(dir.path())).unwrap())
|
||||||
assert_eq!(
|
.collect();
|
||||||
before,
|
let outcomes = auto_install(home);
|
||||||
std::fs::read_to_string(&settings).unwrap(),
|
for (i, target) in Target::ALL.iter().enumerate() {
|
||||||
"already-installed settings must not be rewritten"
|
assert_eq!(
|
||||||
);
|
outcome(&outcomes, *target),
|
||||||
|
AutoInstallOutcome::AlreadyPresent
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
before[i],
|
||||||
|
std::fs::read_to_string(target.settings_path(dir.path())).unwrap(),
|
||||||
|
"already-installed settings must not be rewritten"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An install from an older maestro — present, but missing an event this
|
/// An install from an older maestro — present, but missing an event this
|
||||||
@@ -87,7 +146,7 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let home = dir.path().to_str().unwrap();
|
let home = dir.path().to_str().unwrap();
|
||||||
auto_install(home);
|
auto_install(home);
|
||||||
let settings = dir.path().join(".claude/settings.json");
|
let settings = Target::ClaudeCode.settings_path(dir.path());
|
||||||
let script = maestro_hooks::script_path(dir.path());
|
let script = maestro_hooks::script_path(dir.path());
|
||||||
|
|
||||||
let mut root: serde_json::Value =
|
let mut root: serde_json::Value =
|
||||||
@@ -96,12 +155,23 @@ mod tests {
|
|||||||
std::fs::write(&settings, serde_json::to_string_pretty(&root).unwrap()).unwrap();
|
std::fs::write(&settings, serde_json::to_string_pretty(&root).unwrap()).unwrap();
|
||||||
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
|
std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
|
||||||
|
|
||||||
assert_eq!(auto_install(home), AutoInstallOutcome::Upgraded);
|
let outcomes = auto_install(home);
|
||||||
assert!(maestro_hooks::is_current(&settings, &script));
|
assert_eq!(
|
||||||
|
outcome(&outcomes, Target::ClaudeCode),
|
||||||
|
AutoInstallOutcome::Upgraded
|
||||||
|
);
|
||||||
|
assert!(maestro_hooks::is_current(
|
||||||
|
Target::ClaudeCode,
|
||||||
|
&settings,
|
||||||
|
&script
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unresolvable_home_is_skipped() {
|
fn unresolvable_home_is_skipped() {
|
||||||
assert_eq!(auto_install(""), AutoInstallOutcome::Skipped);
|
let outcomes = auto_install("");
|
||||||
|
for target in Target::ALL {
|
||||||
|
assert_eq!(outcome(&outcomes, target), AutoInstallOutcome::Skipped);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ pub mod hooks;
|
|||||||
pub mod local_api;
|
pub mod local_api;
|
||||||
pub mod panes;
|
pub mod panes;
|
||||||
pub mod proc;
|
pub mod proc;
|
||||||
|
pub mod projection;
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod release;
|
pub mod release;
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::body::Bytes;
|
||||||
|
use axum::extract::{DefaultBodyLimit, Path, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum::routing::{any, delete, get, post};
|
use axum::routing::{any, delete, get, post};
|
||||||
@@ -21,12 +22,14 @@ use maestro_config::SpawnPreset;
|
|||||||
use maestro_protocol::{
|
use maestro_protocol::{
|
||||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
||||||
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
|
RenameSessionRequest, ReportActivityRequest, SendInputRequest, Session, SetFocusRequest,
|
||||||
|
StructuredApprovalDecision, StructuredConfigRequest, StructuredEvent, StructuredPromptRequest,
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::session::SessionManager;
|
use crate::session::{QueueAcpPrompt, SessionError, SessionManager, SetAcpConfig};
|
||||||
|
use crate::sidecar::EventCursor;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct LocalApiState {
|
pub struct LocalApiState {
|
||||||
@@ -64,7 +67,20 @@ pub fn router(state: LocalApiState) -> Router {
|
|||||||
.route(routes::NAMES, get(names))
|
.route(routes::NAMES, get(names))
|
||||||
.route(routes::INFO, get(daemon_info))
|
.route(routes::INFO, get(daemon_info))
|
||||||
.route(routes::EVENTS, any(events_ws))
|
.route(routes::EVENTS, any(events_ws))
|
||||||
.route(routes::patterns::ATTACH, any(attach_ws));
|
.route(routes::patterns::ATTACH, any(attach_ws))
|
||||||
|
.route(routes::patterns::STRUCTURED, any(structured_ws))
|
||||||
|
.route(routes::patterns::STRUCTURED_REPLAY, get(structured_replay))
|
||||||
|
.route(routes::patterns::STRUCTURED_PROMPT, post(structured_prompt))
|
||||||
|
.route(
|
||||||
|
routes::patterns::STRUCTURED_ATTACHMENT,
|
||||||
|
post(structured_attachment).layer(DefaultBodyLimit::max(20 * 1024 * 1024)),
|
||||||
|
)
|
||||||
|
.route(routes::patterns::STRUCTURED_CANCEL, post(structured_cancel))
|
||||||
|
.route(routes::patterns::STRUCTURED_CONFIG, post(structured_config))
|
||||||
|
.route(
|
||||||
|
routes::patterns::STRUCTURED_APPROVAL,
|
||||||
|
post(structured_approval),
|
||||||
|
);
|
||||||
#[cfg(feature = "haiku-autoname")]
|
#[cfg(feature = "haiku-autoname")]
|
||||||
{
|
{
|
||||||
router = router.route(routes::patterns::SESSION_PROMPT, post(session_prompt));
|
router = router.route(routes::patterns::SESSION_PROMPT, post(session_prompt));
|
||||||
@@ -164,12 +180,14 @@ fn dir_error_status(code: &str) -> StatusCode {
|
|||||||
fn session_error_status(code: &str) -> StatusCode {
|
fn session_error_status(code: &str) -> StatusCode {
|
||||||
match code {
|
match code {
|
||||||
"not_found" => StatusCode::NOT_FOUND,
|
"not_found" => StatusCode::NOT_FOUND,
|
||||||
"invalid_env" => StatusCode::BAD_REQUEST,
|
"invalid_env" | "bad_request" => StatusCode::BAD_REQUEST,
|
||||||
// A guard refusal is the daemon doing its job, not a failure (#79).
|
// A guard refusal is the daemon doing its job, not a failure (#79).
|
||||||
"kill_guarded" => StatusCode::CONFLICT,
|
"kill_guarded" => StatusCode::CONFLICT,
|
||||||
// The session outlived its pane: nothing to type into, and nothing
|
// The session outlived its pane: nothing to type into, and nothing
|
||||||
// the daemon can do about it.
|
// the daemon can do about it.
|
||||||
"not_running" => StatusCode::CONFLICT,
|
"not_running" => StatusCode::CONFLICT,
|
||||||
|
"not_terminal" => StatusCode::CONFLICT,
|
||||||
|
"shutting_down" => StatusCode::SERVICE_UNAVAILABLE,
|
||||||
_ => StatusCode::BAD_GATEWAY,
|
_ => StatusCode::BAD_GATEWAY,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,11 +315,10 @@ async fn rename_session(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state.
|
/// `POST /api/sessions/:id/activity` (#105): an agent reports a terminal
|
||||||
/// The report holds above the daemon's inference for a TTL, then inference
|
/// session's state. Structured sessions acknowledge and ignore these reports
|
||||||
/// resumes — an agent that dies mid-run cannot pin its session at `Error`
|
/// because their activity comes from ACP. Unknown session is 404; an unknown
|
||||||
/// forever. Unknown session is 404; an unknown state value is rejected by the
|
/// state value is rejected by the JSON decoder (422). Answers `204 No Content`.
|
||||||
/// JSON decoder (422) rather than coerced. Answers `204 No Content`.
|
|
||||||
async fn report_activity(
|
async fn report_activity(
|
||||||
State(s): State<LocalApiState>,
|
State(s): State<LocalApiState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
@@ -368,6 +385,198 @@ fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
|
|||||||
(status, Json(ApiError::new(code, message))).into_response()
|
(status, Json(ApiError::new(code, message))).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ReplayQuery {
|
||||||
|
since: Option<u64>,
|
||||||
|
before: Option<u64>,
|
||||||
|
limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_STRUCTURED_REPLAY_LIMIT: usize = 500;
|
||||||
|
|
||||||
|
async fn structured_replay(
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Query(query): Query<ReplayQuery>,
|
||||||
|
) -> Response {
|
||||||
|
if query.since.is_some() && query.before.is_some() {
|
||||||
|
return error_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid_cursor",
|
||||||
|
"use either since or before, not both",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let cursor = match query.before {
|
||||||
|
Some(before) => EventCursor::Before(Some(before)),
|
||||||
|
None => EventCursor::Since(query.since.unwrap_or(0)),
|
||||||
|
};
|
||||||
|
match s
|
||||||
|
.manager
|
||||||
|
.read_acp_projection_replay(
|
||||||
|
id,
|
||||||
|
cursor,
|
||||||
|
query
|
||||||
|
.limit
|
||||||
|
.unwrap_or(100)
|
||||||
|
.clamp(1, MAX_STRUCTURED_REPLAY_LIMIT),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(events)) => Json(events).into_response(),
|
||||||
|
// A terminal session, or a structured session whose adapter is no
|
||||||
|
// longer live, cannot provide a coherent replay continuation.
|
||||||
|
Ok(None) => error_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"not_structured",
|
||||||
|
"session is not a live structured session",
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AttachmentQuery {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn structured_attachment(
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Query(query): Query<AttachmentQuery>,
|
||||||
|
bytes: Bytes,
|
||||||
|
) -> Response {
|
||||||
|
let name = query.name;
|
||||||
|
if name.is_empty()
|
||||||
|
|| name.len() > 180
|
||||||
|
|| name == "."
|
||||||
|
|| name == ".."
|
||||||
|
|| name
|
||||||
|
.chars()
|
||||||
|
.any(|c| c.is_control() || c == '/' || c == '\\')
|
||||||
|
{
|
||||||
|
return error_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid_filename",
|
||||||
|
"use a filename without directory separators, up to 180 bytes",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
match s.manager.store_acp_attachment(id, &name, &bytes).await {
|
||||||
|
Ok(Some(path)) => Json(serde_json::json!({ "path": path })).into_response(),
|
||||||
|
Ok(None) => error_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"not_structured",
|
||||||
|
"session is not a live structured session",
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn structured_prompt(
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<StructuredPromptRequest>,
|
||||||
|
) -> Response {
|
||||||
|
match s.manager.queue_acp_prompt(id, body.prompt, None).await {
|
||||||
|
Ok(QueueAcpPrompt::Dispatched) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Ok(QueueAcpPrompt::Queued) => StatusCode::ACCEPTED.into_response(),
|
||||||
|
Ok(QueueAcpPrompt::Full) => error_response(
|
||||||
|
StatusCode::TOO_MANY_REQUESTS,
|
||||||
|
"queue_full",
|
||||||
|
"the prompt queue has reached its capacity",
|
||||||
|
),
|
||||||
|
Ok(QueueAcpPrompt::Unavailable) => error_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"not_structured",
|
||||||
|
"session is not a live structured session",
|
||||||
|
),
|
||||||
|
Ok(QueueAcpPrompt::Failed(message)) => {
|
||||||
|
error_response(StatusCode::BAD_GATEWAY, "acp_prompt_failed", &message)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn structured_cancel(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
|
||||||
|
match s.manager.cancel_acp_turn(id).await {
|
||||||
|
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Ok(false) => error_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"not_structured",
|
||||||
|
"session is not a live structured session",
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn structured_config(
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(body): Json<StructuredConfigRequest>,
|
||||||
|
) -> Response {
|
||||||
|
match s
|
||||||
|
.manager
|
||||||
|
.set_acp_config(id, body.config_id, body.value)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(SetAcpConfig::Updated) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Ok(SetAcpConfig::InvalidValue) => error_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid_config_value",
|
||||||
|
"configuration values must be strings or booleans",
|
||||||
|
),
|
||||||
|
Ok(SetAcpConfig::Unavailable) => error_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"not_structured",
|
||||||
|
"session is not a live structured session",
|
||||||
|
),
|
||||||
|
Ok(SetAcpConfig::Failed(message)) => {
|
||||||
|
error_response(StatusCode::BAD_GATEWAY, "acp_config_failed", &message)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn structured_approval(
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
Path((id, nonce)): Path<(Uuid, String)>,
|
||||||
|
Json(decision): Json<StructuredApprovalDecision>,
|
||||||
|
) -> Response {
|
||||||
|
let decision = match decision {
|
||||||
|
StructuredApprovalDecision::AllowOnce => crate::session::ApprovalDecision::AllowOnce,
|
||||||
|
StructuredApprovalDecision::AllowForSession => {
|
||||||
|
crate::session::ApprovalDecision::AllowForSession
|
||||||
|
}
|
||||||
|
StructuredApprovalDecision::Deny => crate::session::ApprovalDecision::Deny,
|
||||||
|
};
|
||||||
|
match s.manager.resolve_acp_approval(id, &nonce, decision).await {
|
||||||
|
Ok(Some(())) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Ok(None) => error_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"not_structured",
|
||||||
|
"session is not a live structured session",
|
||||||
|
),
|
||||||
|
Err(e) => {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convenience constructor — pulls the bits `LocalApiState` needs from
|
/// Convenience constructor — pulls the bits `LocalApiState` needs from
|
||||||
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
|
/// `DaemonConfig` so callers don't have to assemble it by hand. The project
|
||||||
/// scanner starts with its cache cold; wire
|
/// scanner starts with its cache cold; wire
|
||||||
@@ -405,12 +614,23 @@ async fn attach_ws(
|
|||||||
State(s): State<LocalApiState>,
|
State(s): State<LocalApiState>,
|
||||||
ws: axum::extract::WebSocketUpgrade,
|
ws: axum::extract::WebSocketUpgrade,
|
||||||
) -> Result<Response, Response> {
|
) -> Result<Response, Response> {
|
||||||
let Some((replay, rx)) = s.manager.subscribe_pty_with_replay(id).await else {
|
let (replay, rx) = match s.manager.subscribe_pty_with_replay(id).await {
|
||||||
return Err(error_response(
|
Ok(subscription) => subscription,
|
||||||
StatusCode::NOT_FOUND,
|
Err(SessionError::NotFound(_)) => {
|
||||||
"unknown_session",
|
return Err(error_response(
|
||||||
"no such session on this daemon",
|
StatusCode::NOT_FOUND,
|
||||||
));
|
"unknown_session",
|
||||||
|
"no such session on this daemon",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let info = error.to_error_info();
|
||||||
|
return Err(error_response(
|
||||||
|
session_error_status(&info.code),
|
||||||
|
&info.code,
|
||||||
|
&info.message,
|
||||||
|
));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let manager = s.manager.clone();
|
let manager = s.manager.clone();
|
||||||
Ok(ws.on_upgrade(move |socket| async move {
|
Ok(ws.on_upgrade(move |socket| async move {
|
||||||
@@ -418,6 +638,75 @@ async fn attach_ws(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::result_large_err)]
|
||||||
|
async fn structured_ws(
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
State(s): State<LocalApiState>,
|
||||||
|
ws: axum::extract::WebSocketUpgrade,
|
||||||
|
) -> Result<Response, Response> {
|
||||||
|
let Some((snapshot, state, rx)) = s
|
||||||
|
.manager
|
||||||
|
.subscribe_acp_projection(id, EventCursor::Since(0))
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
let info = e.to_error_info();
|
||||||
|
error_response(session_error_status(&info.code), &info.code, &info.message)
|
||||||
|
})?
|
||||||
|
else {
|
||||||
|
return Err(error_response(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"not_structured",
|
||||||
|
"session is not a live structured session",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
Ok(ws.on_upgrade(move |socket| async move {
|
||||||
|
let _ = pump_structured(socket, snapshot, state, rx).await;
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pump_structured(
|
||||||
|
socket: axum::extract::ws::WebSocket,
|
||||||
|
snapshot: maestro_protocol::TranscriptSnapshot,
|
||||||
|
state: maestro_protocol::ReducedState,
|
||||||
|
mut rx: tokio::sync::broadcast::Receiver<crate::projection::ProjectionUpdate>,
|
||||||
|
) -> Result<(), axum::Error> {
|
||||||
|
use axum::extract::ws::Message;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
|
||||||
|
let (mut sink, mut stream) = socket.split();
|
||||||
|
for event in [
|
||||||
|
StructuredEvent::TranscriptSnapshot { snapshot },
|
||||||
|
StructuredEvent::ReducedState { state },
|
||||||
|
] {
|
||||||
|
if sink
|
||||||
|
.send(Message::Text(
|
||||||
|
serde_json::to_string(&event).expect("structured event encode"),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
update = rx.recv() => match update {
|
||||||
|
Ok(update) => {
|
||||||
|
let event = StructuredEvent::ProjectionUpdate { transcript: update.transcript, state: update.state };
|
||||||
|
if sink.send(Message::Text(serde_json::to_string(&event).expect("structured event encode"))).await.is_err() { break; }
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => break,
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
},
|
||||||
|
frame = stream.next() => match frame {
|
||||||
|
None | Some(Err(_)) | Some(Ok(Message::Close(_))) => break,
|
||||||
|
Some(Ok(_)) => {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// WS /api/events — live catalog deltas for browser/TUI subscribers (NAP-248)
|
// WS /api/events — live catalog deltas for browser/TUI subscribers (NAP-248)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user